Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 756e8a51cca4d62a6a199ed20683514e1b751940


Parents : 82e41b2
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-20T14:19:24-05:00

feat: add RRC +k room keys and harden identity, stamp, plugin, and Nomad security

Changes

44 files changed, 2438 insertions(+), 144 deletions(-)


Diff

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 1a959eab..4106c4a9 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 694ca413..50a7105d 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -200,6 +200,7 @@ from meshchatx.src.backend.message_export_bundle import (
from meshchatx.src.backend.nomadnet_downloader import (
NomadnetFileDownloader,
NomadnetPageDownloader,
+ clear_all_nomadnet_cached_links,
get_cached_active_link,
)
from meshchatx.src.backend.nomadnet_utils import (
@@ -1841,6 +1842,11 @@ class ReticulumMeshChat:
except Exception as e:
print(f"Error while cleaning up RNS links: {e}")
+ def _clear_mesh_link_caches(self):
+ """Drop process-global mesh links so identity switch cannot reuse sessions."""
+ clear_all_cached_links()
+ clear_all_nomadnet_cached_links()
+
def teardown_identity(self):
if self.current_context:
self.running = False
@@ -1849,6 +1855,8 @@ class ReticulumMeshChat:
if identity_hash in self.contexts:
del self.contexts[identity_hash]
self.current_context = None
+ # Drop Nomad and RNS links that may have identified as the prior identity.
+ self._clear_mesh_link_caches()
gc.collect()
def _teardown_all_contexts_for_reload(self):
@@ -2290,6 +2298,7 @@ class ReticulumMeshChat:
self._reset_transport_globals_for_reload()
clear_all_cached_links()
+ clear_all_nomadnet_cached_links()
# Clear Identity globals
RNS.Identity.known_destinations = {}
@@ -2711,6 +2720,10 @@ class ReticulumMeshChat:
self.teardown_identity()
# Give a moment for destinations to clear from transport
await asyncio.sleep(2)
+ else:
+ # Even when keeping the old context alive, never reuse mesh links
+ # that may already be identified as the prior identity.
+ self._clear_mesh_link_caches()
# 3. update main identity file
shutil.copy2(identity_file, main_identity_file)
@@ -10162,7 +10175,9 @@ class ReticulumMeshChat:
@routes.post("/api/v1/rrc/hubs/{hub_hash}/rooms")
async def rrc_hub_join_room(request):
- _, hub, error = _rrc_require_hub(request.match_info.get("hub_hash", ""))
+ manager, hub, error = _rrc_require_hub(
+ request.match_info.get("hub_hash", "")
+ )
if error is not None:
return error
data = await request.json()
@@ -10172,15 +10187,86 @@ class ReticulumMeshChat:
{"message": "A room name is required"},
status=400,
)
- key = data.get("key") or None
+ key = data.get("key")
+ if isinstance(key, str):
+ key = key.strip() or None
+ else:
+ key = None
+ remember = bool(data.get("remember", True))
+ if key is None:
+ with contextlib.suppress(Exception):
+ key = manager.get_room_key(hub, room)
try:
if hub.status == hub.STATUS_CONNECTED:
hub.join_room(room, key=key)
else:
hub.add_room(room)
+ # Persist even while offline so WELCOME auto-rejoin can supply +k.
+ if key and remember:
+ with contextlib.suppress(Exception):
+ manager.remember_room_key(hub, room, key)
except (ValueError, RuntimeError) as e:
return web.json_response({"message": str(e)}, status=400)
- return web.json_response({"hub": hub.to_dict()})
+ return web.json_response(
+ {
+ "hub": hub.to_dict(),
+ "has_stored_key": manager.has_stored_room_key(hub, room),
+ },
+ )
+
+ @routes.get("/api/v1/rrc/hubs/{hub_hash}/room-keys")
+ async def rrc_hub_list_room_keys(request):
+ manager, hub, error = _rrc_require_hub(
+ request.match_info.get("hub_hash", "")
+ )
+ if error is not None:
+ return error
+ return web.json_response({"keys": manager.list_stored_room_keys(hub)})
+
+ @routes.put("/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/key")
+ async def rrc_hub_store_room_key(request):
+ manager, hub, error = _rrc_require_hub(
+ request.match_info.get("hub_hash", "")
+ )
+ if error is not None:
+ return error
+ room = request.match_info.get("room", "")
+ data = await request.json()
+ key = data.get("key")
+ if not isinstance(key, str) or not key.strip():
+ return web.json_response(
+ {"message": "A room key is required"},
+ status=400,
+ )
+ try:
+ manager.remember_room_key(hub, room, key.strip())
+ except (TypeError, ValueError, RuntimeError) as e:
+ return web.json_response({"message": str(e)}, status=400)
+ return web.json_response(
+ {
+ "message": "Room key saved",
+ "has_stored_key": True,
+ },
+ )
+
+ @routes.delete("/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/key")
+ async def rrc_hub_delete_room_key(request):
+ manager, hub, error = _rrc_require_hub(
+ request.match_info.get("hub_hash", "")
+ )
+ if error is not None:
+ return error
+ room = request.match_info.get("room", "")
+ try:
+ deleted = manager.forget_room_key(hub, room)
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
+ return web.json_response(
+ {
+ "message": "Room key removed" if deleted else "No stored room key",
+ "deleted": int(deleted or 0),
+ },
+ )
@routes.delete("/api/v1/rrc/hubs/{hub_hash}/rooms/{room}")
async def rrc_hub_part_room(request):
@@ -10483,6 +10569,32 @@ class ReticulumMeshChat:
manager.delete_room(hub.hub_id, request.match_info.get("room", ""))
return web.json_response({"hub": hub.to_dict()})
+ @routes.put("/api/v1/rrc/servers/{hub_id}/rooms/{room}/key")
+ async def rrc_server_room_set_key(request):
+ manager, hub, error = _rrc_server_require_hub(
+ request.match_info.get("hub_id", ""),
+ )
+ if error is not None:
+ return error
+ room = request.match_info.get("room", "")
+ data = await request.json()
+ raw_key = data.get("key")
+ if raw_key is None or raw_key == "":
+ key = None
+ elif isinstance(raw_key, str):
+ key = raw_key.strip() or None
+ else:
+ return web.json_response(
+ {"message": "Room key must be a string or null"},
+ status=400,
+ )
+ try:
+ hub.set_room_key(room, key)
+ manager.save()
+ except (TypeError, ValueError) as e:
+ return web.json_response({"message": str(e)}, status=400)
+ return web.json_response({"hub": hub.to_dict()})
+
@routes.get("/api/v1/rrc/servers/{hub_id}/members")
async def rrc_server_members(request):
_, hub, error = _rrc_server_require_hub(
@@ -18687,6 +18799,10 @@ class ReticulumMeshChat:
self.local_lxmf_destination.hash,
value,
)
+ if value > 0:
+ self.message_router.enforce_stamps()
+ elif hasattr(self.message_router, "ignore_stamps"):
+ self.message_router.ignore_stamps()
# re-announce to update the stamp cost in announces
self.local_lxmf_destination.display_name = (
self.config.display_name.get()
@@ -19010,6 +19126,7 @@ class ReticulumMeshChat:
self.local_lxmf_destination.hash,
254,
)
+ self.message_router.enforce_stamps()
self.local_lxmf_destination.display_name = (
self.config.display_name.get()
)
@@ -19030,6 +19147,10 @@ class ReticulumMeshChat:
self.local_lxmf_destination.hash,
restore_cost,
)
+ if restore_cost > 0:
+ self.message_router.enforce_stamps()
+ elif hasattr(self.message_router, "ignore_stamps"):
+ self.message_router.ignore_stamps()
self.local_lxmf_destination.display_name = (
self.config.display_name.get()
)
@@ -21930,6 +22051,10 @@ class ReticulumMeshChat:
ctx.local_lxmf_destination.hash,
cost,
)
+ if cost > 0:
+ ctx.message_router.enforce_stamps()
+ elif hasattr(ctx.message_router, "ignore_stamps"):
+ ctx.message_router.ignore_stamps()
try:
ctx.local_lxmf_destination.display_name = ctx.config.display_name.get()
ctx.message_router.announce(

diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index e87c9269..5a699cac 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -22,6 +22,7 @@ from .misc import MiscDAO
from .notification_sounds import NotificationSoundDAO
from .provider import DatabaseProvider
from .ringtones import RingtoneDAO
+from .rrc_room_keys import RrcRoomKeysDAO
from .schema import DatabaseSchema
from .sticker_packs import UserStickerPacksDAO
from .stickers import UserStickersDAO
@@ -89,6 +90,7 @@ class Database:
self.debug_logs = DebugLogsDAO(self.provider)
self.access_attempts = AccessAttemptsDAO(self.provider)
self.crash_history = CrashHistoryDAO(self.provider)
+ self.rrc_room_keys = RrcRoomKeysDAO(self.provider)
self._sqlite_memory_relaxed = False
def initialize(self):
@@ -504,9 +506,19 @@ class Database:
member: str,
target_dir: str,
) -> None:
- dest_path = os.path.abspath(os.path.join(target_dir, member))
- abs_target = os.path.abspath(target_dir)
- if not dest_path.startswith(abs_target + os.sep) and dest_path != abs_target:
+ # Normalize separators and reject absolute / parent traversal early.
+ normalized = member.replace("\\", "/")
+ if (
+ not normalized
+ or normalized.startswith("/")
+ or any(part == ".." for part in normalized.split("/"))
+ ):
+ msg = f"Unsafe zip entry path: {member}"
+ raise DatabaseRestoreError(msg)
+ abs_target = os.path.realpath(target_dir)
+ # realpath resolves existing symlink prefixes (e.g. identity_dir/plugins -> outside).
+ abs_dest = os.path.realpath(os.path.join(target_dir, normalized))
+ if abs_dest != abs_target and not abs_dest.startswith(abs_target + os.sep):
msg = f"Unsafe zip entry path: {member}"
raise DatabaseRestoreError(msg)
zf.extract(member, target_dir)

diff --git a/meshchatx/src/backend/database/rrc_room_keys.py b/meshchatx/src/backend/database/rrc_room_keys.py
new file mode 100644
index 00000000..c54a33a9
--- /dev/null
+++ b/meshchatx/src/backend/database/rrc_room_keys.py
@@ -0,0 +1,87 @@
+# SPDX-License-Identifier: 0BSD
+
+"""DAO for encrypted RRC client room keys."""
+
+from __future__ import annotations
+
+from .provider import DatabaseProvider
+
+
+class RrcRoomKeysDAO:
+ def __init__(self, provider: DatabaseProvider):
+ self.provider = provider
+
+ def upsert(
+ self,
+ hub_hash: str,
+ dest_name: str,
+ room: str,
+ nonce: bytes,
+ ciphertext: bytes,
+ ) -> None:
+ self.provider.execute(
+ """
+ INSERT INTO rrc_room_keys (
+ hub_hash, dest_name, room, nonce, ciphertext, updated_at
+ ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(hub_hash, dest_name, room) DO UPDATE SET
+ nonce = EXCLUDED.nonce,
+ ciphertext = EXCLUDED.ciphertext,
+ updated_at = CURRENT_TIMESTAMP
+ """,
+ (hub_hash, dest_name, room, bytes(nonce), bytes(ciphertext)),
+ )
+
+ def get(self, hub_hash: str, dest_name: str, room: str):
+ return self.provider.fetchone(
+ """
+ SELECT hub_hash, dest_name, room, nonce, ciphertext, updated_at
+ FROM rrc_room_keys
+ WHERE hub_hash = ? AND dest_name = ? AND room = ?
+ """,
+ (hub_hash, dest_name, room),
+ )
+
+ def list_for_hub(self, hub_hash: str, dest_name: str | None = None):
+ if dest_name is None:
+ return self.provider.fetchall(
+ """
+ SELECT hub_hash, dest_name, room, updated_at
+ FROM rrc_room_keys
+ WHERE hub_hash = ?
+ ORDER BY room ASC
+ """,
+ (hub_hash,),
+ )
+ return self.provider.fetchall(
+ """
+ SELECT hub_hash, dest_name, room, updated_at
+ FROM rrc_room_keys
+ WHERE hub_hash = ? AND dest_name = ?
+ ORDER BY room ASC
+ """,
+ (hub_hash, dest_name),
+ )
+
+ def delete(self, hub_hash: str, dest_name: str, room: str) -> int:
+ cursor = self.provider.execute(
+ """
+ DELETE FROM rrc_room_keys
+ WHERE hub_hash = ? AND dest_name = ? AND room = ?
+ """,
+ (hub_hash, dest_name, room),
+ )
+ return int(getattr(cursor, "rowcount", 0) or 0)
+
+ def delete_for_hub(self, hub_hash: str, dest_name: str | None = None) -> int:
+ if dest_name is None:
+ cursor = self.provider.execute(
+ "DELETE FROM rrc_room_keys WHERE hub_hash = ?",
+ (hub_hash,),
+ )
+ else:
+ cursor = self.provider.execute(
+ "DELETE FROM rrc_room_keys WHERE hub_hash = ? AND dest_name = ?",
+ (hub_hash, dest_name),
+ )
+ return int(getattr(cursor, "rowcount", 0) or 0)

diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index d267bd51..36e2d881 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -19,7 +19,7 @@ def _validate_identifier(name: str, label: str = "identifier") -> str:
class DatabaseSchema:
- LATEST_VERSION = 52
+ LATEST_VERSION = 53
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -1607,3 +1607,23 @@ class DatabaseSchema:
) fc ON fc.peer_hash = m.peer_hash
""",
)
+
+ if current_version < 53:
+ # Encrypted client-side RRC room keys for +k rooms (identity-scoped).
+ self._safe_execute(
+ """
+ CREATE TABLE IF NOT EXISTS rrc_room_keys (
+ hub_hash TEXT NOT NULL,
+ dest_name TEXT NOT NULL,
+ room TEXT NOT NULL,
+ nonce BLOB NOT NULL,
+ ciphertext BLOB NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (hub_hash, dest_name, room)
+ )
+ """,
+ )
+ self._safe_execute(
+ "CREATE INDEX IF NOT EXISTS idx_rrc_room_keys_hub "
+ "ON rrc_room_keys(hub_hash, dest_name)",
+ )

diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index cfad244b..c07e6857 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -297,6 +297,12 @@ class IdentityContext:
display_name=self.config.display_name.get(),
stamp_cost=inbound_stamp_cost,
)
+ # Announce stamp cost alone does not drop invalid stamps. Enforce when
+ # a non-zero inbound cost is configured so flood/inbound controls work.
+ if isinstance(inbound_stamp_cost, int) and inbound_stamp_cost > 0:
+ self.message_router.enforce_stamps()
+ elif hasattr(self.message_router, "ignore_stamps"):
+ self.message_router.ignore_stamps()
self.forwarding_manager = ForwardingManager(
self.database,
@@ -592,6 +598,7 @@ class IdentityContext:
self.config.display_name.get() if self.config else None
),
get_name_for_identity_hash=self._rrc_name_for_identity_hash,
+ database=self.database,
)
self.rrc_manager.set_change_callback(
lambda hub: self.app.on_rrc_change(hub, context=self),

diff --git a/meshchatx/src/backend/nomadnet_downloader.py b/meshchatx/src/backend/nomadnet_downloader.py
index 259ac987..befd9683 100644
--- a/meshchatx/src/backend/nomadnet_downloader.py
+++ b/meshchatx/src/backend/nomadnet_downloader.py
@@ -14,6 +14,7 @@ from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike
# Global cache for Nomad Network links (reuse instead of reconnecting per request).
# Protected by _nomadnet_links_lock for callers that may touch Reticulum from multiple threads.
+# Cleared on identity switch / RNS reload. Must not outlive the active IdentityContext.
nomadnet_cached_links: dict[bytes, object] = {}
_nomadnet_link_last_used: dict[bytes, float] = {}
_nomadnet_links_lock = threading.Lock()
@@ -58,6 +59,21 @@ def _teardown_links(links) -> None:
pass
+def clear_all_nomadnet_cached_links() -> int:
+ """Tear down every cached Nomad link (identity switch / RNS reload).
+
+ sweep_stale_links leaves ACTIVE links alone. Those objects are tied to the
+ prior identity / transport and must not be reused after switch.
+ """
+ with _nomadnet_links_lock:
+ to_teardown = list(nomadnet_cached_links.values())
+ count = len(to_teardown)
+ nomadnet_cached_links.clear()
+ _nomadnet_link_last_used.clear()
+ _teardown_links(to_teardown)
+ return count
+
+
def sweep_stale_links():
"""Evict non-ACTIVE, idle, and over-cap links from the global cache."""
now = time.time()
@@ -381,6 +397,24 @@ class NomadnetFileDownloader(NomadnetDownloader):
reticulum=reticulum,
)
+ @staticmethod
+ def _safe_file_name(name) -> str:
+ try:
+ if isinstance(name, (bytes, bytearray)):
+ name = name.decode("utf-8", errors="replace")
+ base = os.path.basename(str(name).replace("\\", "/"))
+ except (AttributeError, TypeError, ValueError):
+ return "downloaded_file"
+ return base or "downloaded_file"
+
+ @staticmethod
+ def _payload_bytes(payload) -> bytes | None:
+ if isinstance(payload, io.BufferedReader):
+ return payload.read()
+ if isinstance(payload, (bytes, bytearray, memoryview)):
+ return bytes(payload)
+ return None
+
def on_download_success(self, request_receipt: RNS.RequestReceipt):
response = request_receipt.response
@@ -388,13 +422,12 @@ class NomadnetFileDownloader(NomadnetDownloader):
file_name = "downloaded_file"
metadata = request_receipt.metadata
if isinstance(metadata, dict) and "name" in metadata:
- try:
- file_path = metadata["name"].decode("utf-8", errors="replace")
- file_name = os.path.basename(file_path)
- except (AttributeError, TypeError):
- pass
+ file_name = self._safe_file_name(metadata["name"])
- payload = response.read()
+ payload = self._payload_bytes(response)
+ if payload is None:
+ self.on_file_download_failure("unsupported_response")
+ return
self.on_file_download_success(file_name, payload)
return
@@ -404,23 +437,25 @@ class NomadnetFileDownloader(NomadnetDownloader):
and len(response) > 1
and isinstance(response[1], dict)
):
- payload = response[0]
+ payload = self._payload_bytes(response[0])
+ if payload is None:
+ self.on_file_download_failure("unsupported_response")
+ return
metadata = response[1]
file_name = "downloaded_file"
if "name" in metadata:
- try:
- file_path = metadata["name"].decode("utf-8", errors="replace")
- file_name = os.path.basename(file_path)
- except (AttributeError, TypeError):
- pass
+ file_name = self._safe_file_name(metadata["name"])
self.on_file_download_success(file_name, payload)
return
try:
- file_name = str(response[0])
- payload = response[1]
+ file_name = self._safe_file_name(response[0])
+ payload = self._payload_bytes(response[1])
+ if payload is None:
+ self.on_file_download_failure("unsupported_response")
+ return
self.on_file_download_success(file_name, payload)
except Exception:
self.on_download_failure("unsupported_response")

diff --git a/meshchatx/src/backend/plugin_manager.py b/meshchatx/src/backend/plugin_manager.py
index c45d8350..490b0029 100644
--- a/meshchatx/src/backend/plugin_manager.py
+++ b/meshchatx/src/backend/plugin_manager.py
@@ -622,8 +622,29 @@ class PluginManager:
with self._lock:
record = self._require_plugin(plugin_id)
self._validate_plugin_runtime(record)
- integrity_hash = compute_dir_integrity_hash(record.install_path)
- record.integrity_hash = integrity_hash
+ # Never re-bless a tampered tree. Reinstall is required to refresh
+ # the integrity hash after on-disk changes.
+ if record.tampered:
+ raise PluginSecurityError(
+ "plugin tree was tampered; reinstall the plugin to enable it",
+ )
+ current_hash = compute_dir_integrity_hash(record.install_path)
+ if record.integrity_hash and current_hash != record.integrity_hash:
+ record.enabled = False
+ record.tampered = True
+ record.auto_disabled_reason = INTEGRITY_TAMPER_MESSAGE
+ self._write_plugin_state(
+ plugin_id,
+ False,
+ INTEGRITY_TAMPER_MESSAGE,
+ integrity_hash=record.integrity_hash,
+ tampered=True,
+ )
+ raise PluginSecurityError(
+ "plugin tree was tampered; reinstall the plugin to enable it",
+ )
+ if not record.integrity_hash:
+ record.integrity_hash = current_hash
record.tampered = False
record.enabled = True
record.auto_disabled_reason = None
@@ -633,7 +654,7 @@ class PluginManager:
plugin_id,
True,
None,
- integrity_hash=integrity_hash,
+ integrity_hash=record.integrity_hash,
tampered=False,
)
if self._backend_type(record) == "python":

diff --git a/meshchatx/src/backend/rrc/hub_commands.py b/meshchatx/src/backend/rrc/hub_commands.py
index cc897821..e3d070e9 100644
--- a/meshchatx/src/backend/rrc/hub_commands.py
+++ b/meshchatx/src/backend/rrc/hub_commands.py
@@ -12,6 +12,7 @@ from meshchatx.src.backend.rrc.identity_util import (
parse_identity_hash,
resolve_identity_hash,
)
+from meshchatx.src.backend.rrc.room_key_crypto import normalize_room_key
from meshchatx.src.backend.rrc.rooms_toml import INVITE_DEFAULT_TTL_S
HELP_TEXT = (
@@ -106,13 +107,23 @@ class HubCommandHandler:
server._queue_notice(outgoing, link, None, "bad room: " + str(e))
return True
st = server.rooms.get_state(r)
- if (
- st is not None
- and st.get("private")
- and not server.policy.is_server_op(peer)
- ):
- server._queue_notice(outgoing, link, None, f"room {r} is private")
- return True
+ if st is not None and st.get("private"):
+ is_member = link in server._room_members.get(r, set())
+ can_see = (
+ is_member
+ or server.rooms.is_room_op(r, peer)
+ or server.policy.is_server_op(peer)
+ )
+ if not can_see:
+ # Same shape as an empty/unknown room so outsiders cannot
+ # distinguish private registered rooms from nonexistent ones.
+ server._queue_notice(
+ outgoing,
+ link,
+ None,
+ "members in " + r + ": (none)",
+ )
+ return True
members = []
for member in server._room_members.get(r, set()):
s = server._sessions.get(member)
@@ -212,11 +223,13 @@ class HubCommandHandler:
if tsess is None or r not in tsess.rooms:
server._queue_notice(outgoing, link, room, "target not in room")
return True
- tsess.rooms.discard(r)
- members = server._room_members.get(r)
- if members is not None:
- members.discard(target_link)
- server._queue_error(outgoing, target_link, f"kicked from {r}", room=r)
+ server._force_leave_room(
+ target_link,
+ tsess,
+ r,
+ outgoing,
+ error_text=f"kicked from {r}",
+ )
server._queue_notice(outgoing, link, room, f"kicked {parts[2]} from {r}")
return True
@@ -462,7 +475,11 @@ class HubCommandHandler:
if not key:
server._queue_notice(outgoing, link, room, "key must not be empty")
return True
- st["key"] = key
+ try:
+ st["key"] = normalize_room_key(key)
+ except ValueError as e:
+ server._queue_notice(outgoing, link, room, str(e))
+ return True
else:
st["key"] = None
elif flag in ("+r", "-r"):
@@ -541,6 +558,9 @@ class HubCommandHandler:
op = parts[2].lower()
st = server.rooms.ensure_state(r)
bans = st.setdefault("bans", set())
+ if not server.rooms.is_room_op(r, peer):
+ server._queue_error(outgoing, link, "not authorized", room=r)
+ return True
if op == "list":
if not bans:
server._queue_notice(outgoing, link, room, f"no bans in {r}")
@@ -569,9 +589,6 @@ class HubCommandHandler:
f"usage: /ban {r} {op} <nick|hashprefix|hash>",
)
return True
- if not server.rooms.is_room_op(r, peer):
- server._queue_error(outgoing, link, "not authorized", room=r)
- return True
target_hash, matches = resolve_identity_hash(server, parts[3], room=r)
if target_hash is None:
server._queue_notice(
@@ -589,13 +606,12 @@ class HubCommandHandler:
ms = server._sessions.get(member)
if ms is not None and isinstance(ms.peer, (bytes, bytearray)):
if bytes(ms.peer) == target_hash:
- ms.rooms.discard(r)
- server._room_members[r].discard(member)
- server._queue_error(
- outgoing,
+ server._force_leave_room(
member,
- f"banned from {r}",
- room=r,
+ ms,
+ r,
+ outgoing,
+ error_text=f"banned from {r}",
)
server._queue_notice(outgoing, link, room, f"ban added in {r}")
else:

diff --git a/meshchatx/src/backend/rrc/manager.py b/meshchatx/src/backend/rrc/manager.py
index d17b7a6d..8ec76d64 100644
--- a/meshchatx/src/backend/rrc/manager.py
+++ b/meshchatx/src/backend/rrc/manager.py
@@ -18,8 +18,13 @@ from collections import deque
import RNS
from meshchatx.src.backend.rrc import protocol as proto
+from meshchatx.src.backend.rrc.room_key_crypto import decrypt_room_key, encrypt_room_key
from meshchatx.src.backend.rrc.server import _LoopbackEndpoint
+DEFAULT_DEST_NAME = proto.DEFAULT_DEST_NAME
+BAD_KEY_MARKERS = ("bad key (+k)", "bad key")
+FORCED_LEAVE_MARKERS = ("kicked from", "banned from")
+
HISTORY_DIR_NAME = "rrc_history"
HISTORY_FILENAME_SANITIZE_RE = re.compile(r"[^a-z0-9._-]+")
@@ -557,7 +562,11 @@ class RRCHub:
def join_room(self, room, key=None, silent=False):
r = proto.normalize_room(room)
- body = key if (isinstance(key, str) and key) else None
+ body = None
+ if isinstance(key, str):
+ stripped = key.strip()
+ if stripped:
+ body = stripped
env = proto.make_envelope(
proto.T_JOIN,
src=self.manager.identity.hash,
@@ -1233,6 +1242,7 @@ class RRCHub:
text = body if isinstance(body, str) else "(error)"
r = room.strip().lower() if isinstance(room, str) else None
rollback_join = False
+ forced_leave = False
if r:
with self._lock:
if r in self._pending_joins:
@@ -1242,8 +1252,18 @@ class RRCHub:
self._pending_parts.discard(r)
if rollback_join:
self.rooms.discard(r)
- if rollback_join:
+ elif self.manager.is_forced_leave_error(text) and r in self.rooms:
+ forced_leave = True
+ self.rooms.discard(r)
+ self.members.pop(r, None)
+ self.unread_rooms.discard(r)
+ self.mention_rooms.discard(r)
+ if rollback_join or forced_leave:
self.manager.save()
+ # Drop a remembered key that the hub rejected so reconnect does not loop.
+ if self.manager.is_bad_key_error(text):
+ with contextlib.suppress(Exception):
+ self.manager.forget_room_key(self, r)
msg = proto.RRCMessage("error", r, None, None, text, proto.now_ms())
self._record_notice(msg)
@@ -1356,6 +1376,11 @@ class RRCHub:
def to_dict(self):
"""Return a JSON-serializable summary of this hub's state."""
+ stored_key_rooms = []
+ with contextlib.suppress(Exception):
+ stored_key_rooms = [
+ entry["room"] for entry in self.manager.list_stored_room_keys(self)
+ ]
with self._lock:
rooms = sorted(self.rooms)
known_rooms = self.ordered_known_rooms()
@@ -1382,6 +1407,7 @@ class RRCHub:
"total_unread": total_unread,
"mention_rooms": sorted(self.mention_rooms),
"available_rooms": dict(self.available_rooms),
+ "stored_key_rooms": stored_key_rooms,
"auto_reconnect": bool(self.auto_reconnect),
"auto_list": bool(self.auto_list),
"auto_who": bool(self.auto_who),
@@ -1428,6 +1454,7 @@ class RRCManager:
history_per_room_cap=0,
filter_loaded_history=True,
ephemeral_notices=RRCHub.SYS_NOTICE_TIMEOUT,
+ database=None,
):
self.identity = identity
self.storage_dir = storage_dir
@@ -1436,6 +1463,7 @@ class RRCManager:
self.history_per_room_cap = history_per_room_cap
self.filter_loaded_history = filter_loaded_history
self.ephemeral_notices = ephemeral_notices
+ self.database = database
self.hubs = []
self._server_manager = None
@@ -1448,6 +1476,136 @@ class RRCManager:
self._loading = False
self._save_lock = threading.Lock()
+ def set_database(self, database):
+ self.database = database
+
+ def _private_key_bytes(self):
+ identity = self.identity
+ if identity is None:
+ return None
+ with contextlib.suppress(Exception):
+ key = identity.get_private_key()
+ if isinstance(key, (bytes, bytearray)) and key:
+ return bytes(key)
+ return None
+
+ def _room_key_dao(self):
+ db = self.database
+ if db is None:
+ return None
+ return getattr(db, "rrc_room_keys", None)
+
+ @staticmethod
+ def _hub_hash_hex(hub_or_hash):
+ if isinstance(hub_or_hash, RRCHub):
+ return hub_or_hash.hub_hash.hex()
+ if isinstance(hub_or_hash, (bytes, bytearray)):
+ return bytes(hub_or_hash).hex()
+ if isinstance(hub_or_hash, str):
+ return hub_or_hash.strip().lower()
+ msg = "invalid hub hash"
+ raise TypeError(msg)
+
+ @staticmethod
+ def _dest_name_for(hub_or_dest):
+ if isinstance(hub_or_dest, RRCHub):
+ return hub_or_dest.dest_name or DEFAULT_DEST_NAME
+ if isinstance(hub_or_dest, str) and hub_or_dest.strip():
+ return hub_or_dest.strip()
+ return DEFAULT_DEST_NAME
+
+ def remember_room_key(self, hub, room, key):
+ """Encrypt and persist a room key for later joins."""
+ dao = self._room_key_dao()
+ private_key = self._private_key_bytes()
+ if dao is None or private_key is None:
+ msg = "room key storage is unavailable"
+ raise RuntimeError(msg)
+ room_n = proto.normalize_room(room)
+ nonce, ciphertext = encrypt_room_key(private_key, key)
+ dao.upsert(
+ self._hub_hash_hex(hub),
+ self._dest_name_for(hub),
+ room_n,
+ nonce,
+ ciphertext,
+ )
+
+ def forget_room_key(self, hub, room):
+ dao = self._room_key_dao()
+ if dao is None:
+ return 0
+ room_n = proto.normalize_room(room)
+ return dao.delete(
+ self._hub_hash_hex(hub),
+ self._dest_name_for(hub),
+ room_n,
+ )
+
+ def get_room_key(self, hub, room):
+ """Return the decrypted room key, or None when missing or undecryptable."""
+ dao = self._room_key_dao()
+ private_key = self._private_key_bytes()
+ if dao is None or private_key is None:
+ return None
+ room_n = proto.normalize_room(room)
+ row = dao.get(
+ self._hub_hash_hex(hub),
+ self._dest_name_for(hub),
+ room_n,
+ )
+ if not row:
+ return None
+ try:
+ return decrypt_room_key(private_key, row["nonce"], row["ciphertext"])
+ except Exception:
+ return None
+
+ def has_stored_room_key(self, hub, room):
+ dao = self._room_key_dao()
+ if dao is None:
+ return False
+ room_n = proto.normalize_room(room)
+ row = dao.get(
+ self._hub_hash_hex(hub),
+ self._dest_name_for(hub),
+ room_n,
+ )
+ return row is not None
+
+ def list_stored_room_keys(self, hub):
+ dao = self._room_key_dao()
+ if dao is None:
+ return []
+ rows = dao.list_for_hub(
+ self._hub_hash_hex(hub),
+ self._dest_name_for(hub),
+ )
+ return [
+ {
+ "hub_hash": row["hub_hash"],
+ "dest_name": row["dest_name"],
+ "room": row["room"],
+ "updated_at": row.get("updated_at"),
+ }
+ for row in rows or []
+ ]
+
+ @staticmethod
+ def is_bad_key_error(text):
+ if not isinstance(text, str):
+ return False
+ lowered = text.strip().lower()
+ # Require the words "bad key" so mode hints like "enable +k" do not wipe storage.
+ return any(marker in lowered for marker in BAD_KEY_MARKERS)
+
+ @staticmethod
+ def is_forced_leave_error(text):
+ if not isinstance(text, str):
+ return False
+ lowered = text.strip().lower()
+ return any(marker in lowered for marker in FORCED_LEAVE_MARKERS)
+
def get_nickname(self):
if self._get_nickname is None:
return None
@@ -1490,7 +1648,8 @@ class RRCManager:
def _on_welcome(self, hub):
for r in list(hub.rooms):
with contextlib.suppress(Exception):
- hub.join_room(r, silent=True)
+ key = self.get_room_key(hub, r)
+ hub.join_room(r, key=key, silent=True)
def set_active(self, hub, room):
self._active_hub = hub
@@ -1526,6 +1685,13 @@ class RRCManager:
self.hubs.remove(hub)
with contextlib.suppress(Exception):
hub.disconnect()
+ dao = self._room_key_dao()
+ if dao is not None:
+ with contextlib.suppress(Exception):
+ dao.delete_for_hub(
+ self._hub_hash_hex(hub),
+ self._dest_name_for(hub),
+ )
self.save()
self._notify_change()

diff --git a/meshchatx/src/backend/rrc/room_key_crypto.py b/meshchatx/src/backend/rrc/room_key_crypto.py
new file mode 100644
index 00000000..9d076087
--- /dev/null
+++ b/meshchatx/src/backend/rrc/room_key_crypto.py
@@ -0,0 +1,96 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Encrypt RRC room keys at rest with AES-GCM.
+
+Wrapping keys are derived from the active Reticulum identity private key via
+HKDF so secrets stay identity-scoped and become unreadable after an identity
+switch without copying the private key material into the database.
+"""
+
+from __future__ import annotations
+
+import hmac
+import os
+
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+
+_HKDF_SALT = b"meshchatx-rrc-room-keys-v1"
+_HKDF_INFO = b"rrc-room-key-wrap"
+_NONCE_LEN = 12
+_KEY_LEN = 32
+MAX_ROOM_KEY_BYTES = 256
+
+
+def normalize_room_key(room_key: str) -> str:
+ """Strip and validate a room key string for storage or JOIN compare."""
+ if not isinstance(room_key, str):
+ msg = "room key must be a string"
+ raise TypeError(msg)
+ key = room_key.strip()
+ if not key:
+ msg = "room key must not be empty"
+ raise ValueError(msg)
+ encoded = key.encode("utf-8")
+ if len(encoded) > MAX_ROOM_KEY_BYTES:
+ msg = f"room key must be at most {MAX_ROOM_KEY_BYTES} bytes"
+ raise ValueError(msg)
+ return key
+
+
+def room_keys_equal(provided: str | None, expected: str) -> bool:
+ """Constant-time compare of stripped JOIN body against stored hub key."""
+ if not isinstance(expected, str) or not expected:
+ return False
+ if not isinstance(provided, str):
+ return False
+ try:
+ left = normalize_room_key(provided).encode("utf-8")
+ except (TypeError, ValueError):
+ return False
+ right = expected.encode("utf-8")
+ if len(left) != len(right):
+ hmac.compare_digest(left, left)
+ return False
+ return hmac.compare_digest(left, right)
+
+
+def derive_wrap_key(identity_private_key: bytes) -> bytes:
+ if not isinstance(identity_private_key, (bytes, bytearray)):
+ msg = "identity private key must be bytes"
+ raise TypeError(msg)
+ if not identity_private_key:
+ msg = "identity private key must not be empty"
+ raise ValueError(msg)
+ return HKDF(
+ algorithm=hashes.SHA256(),
+ length=_KEY_LEN,
+ salt=_HKDF_SALT,
+ info=_HKDF_INFO,
+ ).derive(bytes(identity_private_key))
+
+
+def encrypt_room_key(identity_private_key: bytes, room_key: str) -> tuple[bytes, bytes]:
+ """Return (nonce, ciphertext) for a room key."""
+ plaintext = normalize_room_key(room_key).encode("utf-8")
+ wrap_key = derive_wrap_key(identity_private_key)
+ nonce = os.urandom(_NONCE_LEN)
+ ciphertext = AESGCM(wrap_key).encrypt(nonce, plaintext, None)
+ return nonce, ciphertext
+
+
+def decrypt_room_key(
+ identity_private_key: bytes,
+ nonce: bytes,
+ ciphertext: bytes,
+) -> str:
+ if not isinstance(nonce, (bytes, bytearray)) or len(nonce) != _NONCE_LEN:
+ msg = "invalid room key nonce"
+ raise ValueError(msg)
+ if not isinstance(ciphertext, (bytes, bytearray)) or not ciphertext:
+ msg = "invalid room key ciphertext"
+ raise ValueError(msg)
+ wrap_key = derive_wrap_key(identity_private_key)
+ plaintext = AESGCM(wrap_key).decrypt(bytes(nonce), bytes(ciphertext), None)
+ return plaintext.decode("utf-8")

diff --git a/meshchatx/src/backend/rrc/room_registry.py b/meshchatx/src/backend/rrc/room_registry.py
index b586c66a..09376fde 100644
--- a/meshchatx/src/backend/rrc/room_registry.py
+++ b/meshchatx/src/backend/rrc/room_registry.py
@@ -4,6 +4,7 @@
import time
+from meshchatx.src.backend.rrc.room_key_crypto import normalize_room_key
from meshchatx.src.backend.rrc.rooms_toml import INVITE_DEFAULT_TTL_S
@@ -94,7 +95,12 @@ class RoomRegistry:
st[flag] = bool(kwargs[flag])
if "key" in kwargs:
key = kwargs["key"]
- st["key"] = key if isinstance(key, str) and key else None
+ if key is None or (isinstance(key, str) and not str(key).strip()):
+ st["key"] = None
+ elif isinstance(key, str):
+ st["key"] = normalize_room_key(key)
+ else:
+ st["key"] = None
if "founder" in kwargs and kwargs["founder"] is not None:
st["founder"] = kwargs["founder"]
st.setdefault("ops", set()).add(kwargs["founder"])
@@ -217,6 +223,16 @@ class RoomRegistry:
return False
return True
+ def consume_invite(self, room, peer_hash) -> bool:
+ """Remove a still-valid invite. Returns True if one was present."""
+ if not self.is_invited(room, peer_hash):
+ return False
+ st = self.ensure_state(room)
+ inv = st.get("invited")
+ if not isinstance(inv, dict):
+ return False
+ return inv.pop(bytes(peer_hash), None) is not None
+
def prune_expired_invites(self, room):
st = self.ensure_state(room)
inv = st.get("invited")

diff --git a/meshchatx/src/backend/rrc/rooms_toml.py b/meshchatx/src/backend/rrc/rooms_toml.py
index 7c714a51..97e73afb 100644
--- a/meshchatx/src/backend/rrc/rooms_toml.py
+++ b/meshchatx/src/backend/rrc/rooms_toml.py
@@ -126,7 +126,7 @@ def dump_rooms_registry(path, registry):
st = registry[room]
if not st.get("registered"):
continue
- lines.append(f"[rooms.{room}]")
+ lines.append(f"[rooms.{_toml_escape_string(room)}]")
founder = st.get("founder")
if isinstance(founder, (bytes, bytearray)):
lines.append(f"founder = {_toml_escape_string(bytes(founder).hex())}")

diff --git a/meshchatx/src/backend/rrc/server.py b/meshchatx/src/backend/rrc/server.py
index e7694217..75d92bd3 100644
--- a/meshchatx/src/backend/rrc/server.py
+++ b/meshchatx/src/backend/rrc/server.py
@@ -21,6 +21,10 @@ from meshchatx.src.backend.rrc import protocol as proto
from meshchatx.src.backend.rrc.hub_commands import HubCommandHandler
from meshchatx.src.backend.rrc.hub_policy import HubPolicy
from meshchatx.src.backend.rrc.identity_util import parse_identity_hash
+from meshchatx.src.backend.rrc.room_key_crypto import (
+ normalize_room_key,
+ room_keys_equal,
+)
from meshchatx.src.backend.rrc.room_registry import RoomRegistry
from meshchatx.src.backend.rrc.rooms_toml import RoomsTomlStore
@@ -498,41 +502,52 @@ class RRCHubServer:
peer = sess.peer if isinstance(sess.peer, (bytes, bytearray)) else None
join_body = env.get(proto.K_BODY)
- if peer is not None and self.rooms.is_room_banned(r, peer):
+ # Missing identity must deny ACL paths. Skipping checks would bypass +k/+i.
+ if peer is None:
+ self._queue_error(outgoing, link, "not identified")
+ return
+ if self.rooms.is_room_banned(r, peer):
self._queue_error(outgoing, link, "banned from room", room=r)
return
- st = self.rooms.ensure_state(r, founder=peer)
+ # Do not promote founder/ops until +i/+k checks pass. Otherwise the
+ # first JOIN into a keyed room with no founder bypasses the key.
+ st = self.rooms.ensure_state(r)
if bool(st.get("invite_only")):
- if (
- peer is not None
- and not self.rooms.is_room_op(r, peer)
- and not self.rooms.is_invited(r, peer)
+ if not self.rooms.is_room_op(r, peer) and not self.rooms.is_invited(
+ r,
+ peer,
):
self._queue_error(outgoing, link, "invite-only (+i)", room=r)
return
key = st.get("key")
if isinstance(key, str) and key:
- if (
- peer is not None
- and not self.rooms.is_room_op(r, peer)
- and not self.rooms.is_invited(r, peer)
+ if not self.rooms.is_room_op(r, peer) and not self.rooms.is_invited(
+ r,
+ peer,
):
- provided = join_body if isinstance(join_body, str) else None
- if provided != key:
+ if not room_keys_equal(
+ join_body if isinstance(join_body, str) else None, key
+ ):
self._queue_error(outgoing, link, "bad key (+k)", room=r)
return
if not self._room_members.get(r):
self.rooms.ensure_state(r, founder=peer)
+ # One-shot invite: consume after a successful ACL pass so PART+reJOIN
+ # cannot reuse the same invite forever while TTL is live.
+ if self.rooms.consume_invite(r, peer):
+ self.rooms.persist(r)
+
members = self._room_members.setdefault(r, set())
+ already = link in members
existing = [m for m in members if m != link]
members.add(link)
sess.rooms.add(r)
- if existing:
+ if existing and not already:
fanout = proto.encode(
proto.make_envelope(
proto.T_JOINED,
@@ -590,6 +605,30 @@ class RRCHubServer:
self._queue_error(outgoing, link, str(e))
return
+ if r not in sess.rooms:
+ self._queue_error(outgoing, link, "not in room", room=r)
+ return
+
+ self._force_leave_room(
+ link,
+ sess,
+ r,
+ outgoing,
+ ack_parted=True,
+ )
+
+ def _force_leave_room(
+ self,
+ link,
+ sess,
+ room,
+ outgoing,
+ *,
+ error_text=None,
+ ack_parted=False,
+ ):
+ """Remove a link from a room and fan out PARTED to remaining members."""
+ r = room
sess.rooms.discard(r)
members = self._room_members.get(r)
remaining = []
@@ -613,19 +652,22 @@ class RRCHubServer:
for member in remaining:
outgoing.append((member, fanout))
- outgoing.append(
- (
- link,
- proto.encode(
- proto.make_envelope(
- proto.T_PARTED,
- src=self.identity.hash,
- room=r,
- body=[sess.peer] if sess.peer is not None else None,
+ if error_text:
+ self._queue_error(outgoing, link, error_text, room=r)
+ elif ack_parted:
+ outgoing.append(
+ (
+ link,
+ proto.encode(
+ proto.make_envelope(
+ proto.T_PARTED,
+ src=self.identity.hash,
+ room=r,
+ body=[sess.peer] if sess.peer is not None else None,
+ ),
),
),
- ),
- )
+ )
self.manager._notify_change(self)
def _handle_message(self, link, sess, env, outgoing):
@@ -807,6 +849,24 @@ class RRCHubServer:
if st.get("registered"):
self.rooms.persist(r)
+ def set_room_key(self, name, key):
+ """Set or clear the +k room key. Empty/None clears the key."""
+ r = proto.normalize_room(name)
+ st = self.rooms.ensure_state(r)
+ if key is None or (isinstance(key, str) and not key.strip()):
+ st["key"] = None
+ elif isinstance(key, str):
+ st["key"] = normalize_room_key(key)
+ else:
+ msg = "room key must be a string or null"
+ raise TypeError(msg)
+ if st.get("registered"):
+ self.rooms.persist(r)
+ outgoing = []
+ self.rooms.broadcast_mode(r, outgoing)
+ self._flush_outgoing(outgoing)
+ return r
+
def client_count(self):
with self._lock:
return sum(1 for s in self._sessions.values() if s.welcomed)
@@ -907,8 +967,8 @@ class RRCHubServer:
raise ValueError(msg)
r = self._norm_room(room)
outgoing = []
+ target_link = None
with self._lock:
- target_link = None
for link, sess in self._sessions.items():
if (
isinstance(sess.peer, (bytes, bytearray))
@@ -917,15 +977,17 @@ class RRCHubServer:
):
target_link = link
break
- if target_link is None:
- msg = "peer not in room"
- raise ValueError(msg)
- tsess = self._sessions[target_link]
- tsess.rooms.discard(r)
- members = self._room_members.get(r)
- if members is not None:
- members.discard(target_link)
- self._queue_error(outgoing, target_link, "kicked from " + r, room=r)
+ if target_link is None:
+ msg = "peer not in room"
+ raise ValueError(msg)
+ tsess = self._sessions[target_link]
+ self._force_leave_room(
+ target_link,
+ tsess,
+ r,
+ outgoing,
+ error_text="kicked from " + r,
+ )
self._flush_outgoing(outgoing)
return True
@@ -958,13 +1020,12 @@ class RRCHubServer:
ms = self._sessions.get(member)
if ms is not None and isinstance(ms.peer, (bytes, bytearray)):
if bytes(ms.peer) == peer:
- ms.rooms.discard(r)
- self._room_members[r].discard(member)
- self._queue_error(
- outgoing,
+ self._force_leave_room(
member,
- "banned from " + r,
- room=r,
+ ms,
+ r,
+ outgoing,
+ error_text="banned from " + r,
)
self._flush_outgoing(outgoing)
return True

diff --git a/meshchatx/src/frontend/components/PromptDialog.vue b/meshchatx/src/frontend/components/PromptDialog.vue
index 6f7180a1..f413fda5 100644
--- a/meshchatx/src/frontend/components/PromptDialog.vue
+++ b/meshchatx/src/frontend/components/PromptDialog.vue
@@ -29,8 +29,9 @@
<input
ref="promptInput"
v-model="inputValue"
- type="text"
+ :type="inputType"
class="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500"
+ autocomplete="off"
@keydown.enter.prevent="confirm"
@keydown.esc.prevent="cancel"
/>
@@ -71,6 +72,7 @@ export default {
pendingPrompt: null,
resolvePromise: null,
inputValue: "",
+ inputType: "text",
};
},
mounted() {
@@ -80,9 +82,10 @@ export default {
GlobalEmitter.off("prompt", this.show);
},
methods: {
- show({ message, defaultValue, resolve }) {
+ show({ message, defaultValue, resolve, inputType }) {
this.pendingPrompt = { message };
this.inputValue = defaultValue == null ? "" : String(defaultValue);
+ this.inputType = inputType === "password" ? "password" : "text";
this.resolvePromise = resolve;
this.$nextTick(() => {
const input = this.$refs.promptInput;
@@ -99,6 +102,7 @@ export default {
}
this.pendingPrompt = null;
this.inputValue = "";
+ this.inputType = "text";
},
cancel() {
if (this.resolvePromise) {
@@ -107,6 +111,7 @@ export default {
}
this.pendingPrompt = null;
this.inputValue = "";
+ this.inputType = "text";
},
},
};

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 6a42d80b..3ff57cbd 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -1967,17 +1967,33 @@ export default {
const placeholders = container.querySelectorAll(".mu-partial");
if (placeholders.length === 0) return;
+ // Hostile pages can emit many partials / 1s refresh loops. Cap fan-out
+ // and floor refresh so browsing cannot coerce unbounded mesh traffic.
+ const MAX_PARTIAL_PLACEHOLDERS = 32;
+ const MAX_PARTIAL_FETCHES_PER_PASS = 8;
+ const MIN_PARTIAL_REFRESH_SEC = 5;
+
const idsByKey = {};
const refreshByKey = {};
const needLoad = new Set();
const fieldsByKey = {};
+ let seenPlaceholders = 0;
placeholders.forEach((el) => {
+ if (seenPlaceholders >= MAX_PARTIAL_PLACEHOLDERS) {
+ return;
+ }
+ seenPlaceholders += 1;
const id = el.getAttribute("data-partial-id");
const dest = el.getAttribute("data-dest");
const path = el.getAttribute("data-path");
const refreshAttr = el.getAttribute("data-refresh");
- const refresh = refreshAttr ? parseInt(refreshAttr, 10) : null;
+ let refresh = refreshAttr ? parseInt(refreshAttr, 10) : null;
+ if (Number.isFinite(refresh) && refresh > 0) {
+ refresh = Math.max(refresh, MIN_PARTIAL_REFRESH_SEC);
+ } else {
+ refresh = null;
+ }
const fieldsStr = el.getAttribute("data-fields");
const key = dest + ":" + path;
if (!idsByKey[key]) idsByKey[key] = [];
@@ -2018,7 +2034,12 @@ export default {
}
}
};
+ let fetchBudget = MAX_PARTIAL_FETCHES_PER_PASS;
needLoad.forEach((key) => {
+ if (fetchBudget <= 0) {
+ return;
+ }
+ fetchBudget -= 1;
const colon = key.indexOf(":");
const dest = key.slice(0, colon);
const path = colon >= 0 ? key.slice(colon + 1) : "";

diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index b497de66..59eb5494 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -295,20 +295,29 @@
</ul>
</div>
- <form class="flex gap-1" @submit.prevent="joinRoom(hub)">
+ <form class="flex flex-col gap-1" @submit.prevent="joinRoom(hub)">
+ <div class="flex gap-1">
+ <input
+ v-model="joinRoomName"
+ type="text"
+ :placeholder="$t('relay_chat.join_room_placeholder')"
+ class="min-w-0 flex-1 border border-sem-border bg-sem-canvas px-2 py-1 text-xs text-sem-fg outline-none focus:border-sem-accent focus:ring-1 focus:ring-sem-accent/30"
+ />
+ <button
+ type="submit"
+ class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-sem-fg-muted transition-colors hover:bg-sem-surface/60 hover:text-sem-accent"
+ :title="$t('relay_chat.join_room')"
+ >
+ <MaterialDesignIcon icon-name="plus" class="size-4" />
+ </button>
+ </div>
<input
- v-model="joinRoomName"
- type="text"
- :placeholder="$t('relay_chat.join_room_placeholder')"
- class="min-w-0 flex-1 border border-sem-border bg-sem-canvas px-2 py-1 text-xs text-sem-fg outline-none focus:border-sem-accent focus:ring-1 focus:ring-sem-accent/30"
+ v-model="joinRoomKey"
+ type="password"
+ :placeholder="$t('relay_chat.join_room_key_placeholder')"
+ autocomplete="off"
+ class="w-full border border-sem-border bg-sem-canvas px-2 py-1 text-xs text-sem-fg outline-none focus:border-sem-accent focus:ring-1 focus:ring-sem-accent/30"
/>
- <button
- type="submit"
- class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-sem-fg-muted transition-colors hover:bg-sem-surface/60 hover:text-sem-accent"
- :title="$t('relay_chat.join_room')"
- >
- <MaterialDesignIcon icon-name="plus" class="size-4" />
- </button>
</form>
</div>
</div>
@@ -1480,6 +1489,8 @@ export default {
composer: "",
sending: false,
joinRoomName: "",
+ joinRoomKey: "",
+ badKeyPromptInFlight: null,
showAddHub: false,
addHubAdvancedOpen: false,
addHubForm: {
@@ -2660,22 +2671,87 @@ export default {
ToastUtils.warning(this.$t("relay_chat.room_required"));
return;
}
- const joined = await this.joinRoomByName(hub, room);
+ const key = this.joinRoomKey.trim() || null;
+ const joined = await this.joinRoomByName(hub, room, { key });
if (joined) {
this.joinRoomName = "";
+ this.joinRoomKey = "";
}
},
async joinAvailableRoom(hub, room) {
await this.joinRoomByName(hub, room);
},
- async joinRoomByName(hub, room) {
+ isBadKeyErrorText(text) {
+ if (typeof text !== "string") {
+ return false;
+ }
+ const lowered = text.trim().toLowerCase();
+ return lowered.includes("bad key") || lowered.includes("+k");
+ },
+ async promptForRoomKey(room) {
+ const entered = await DialogUtils.prompt(this.$t("relay_chat.room_key_prompt", { room: room || "" }), "", {
+ inputType: "password",
+ });
+ if (entered == null) {
+ return null;
+ }
+ const key = String(entered).trim();
+ return key || null;
+ },
+ async handleBadKeyError(hubHash, room, messageText) {
+ const roomName = (room || "").trim().toLowerCase();
+ if (!hubHash || !roomName) {
+ ToastUtils.error(messageText || this.$t("relay_chat.bad_key"));
+ return;
+ }
+ const flightKey = `${hubHash}:${roomName}`;
+ if (this.badKeyPromptInFlight === flightKey) {
+ return;
+ }
+ this.badKeyPromptInFlight = flightKey;
try {
- await window.api.post(`/api/v1/rrc/hubs/${hub.hub_hash}/rooms`, { room });
- ToastUtils.success(this.$t("relay_chat.joined_room"));
+ ToastUtils.warning(this.$t("relay_chat.bad_key"));
+ const hub = this.hubs.find((h) => h.hub_hash === hubHash);
+ if (!hub) {
+ return;
+ }
+ try {
+ await window.api.delete(`/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(roomName)}/key`);
+ } catch {
+ // Stored key may already be gone after the hub ERROR path.
+ }
+ const key = await this.promptForRoomKey(roomName);
+ if (!key) {
+ return;
+ }
+ await this.joinRoomByName(hub, roomName, { key, prompted: true });
+ } finally {
+ if (this.badKeyPromptInFlight === flightKey) {
+ this.badKeyPromptInFlight = null;
+ }
+ }
+ },
+ async joinRoomByName(hub, room, { key = null, prompted = false } = {}) {
+ try {
+ const payload = { room, remember: true };
+ if (key) {
+ payload.key = key;
+ }
+ await window.api.post(`/api/v1/rrc/hubs/${hub.hub_hash}/rooms`, payload);
+ if (hub.connected || hub.status === 2) {
+ ToastUtils.info(this.$t("relay_chat.join_requested"));
+ } else {
+ ToastUtils.success(this.$t("relay_chat.joined_room"));
+ }
await this.fetchHubs();
return true;
} catch (e) {
- ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ const message = e.response?.data?.message || this.$t("relay_chat.action_failed");
+ if (!prompted && this.isBadKeyErrorText(message)) {
+ await this.handleBadKeyError(hub.hub_hash, room, message);
+ return false;
+ }
+ ToastUtils.error(message);
return false;
}
},
@@ -3119,6 +3195,9 @@ export default {
if (json.type === "rrc.change") {
this.fetchHubs();
} else if (json.type === "rrc.message") {
+ if (json.message && json.message.kind === "error" && this.isBadKeyErrorText(json.message.text)) {
+ this.handleBadKeyError(json.hub_hash, json.room || json.message.room, json.message.text);
+ }
if (json.hub_hash === this.selectedHubHash && json.room === this.selectedRoom && json.message) {
if (!relayMessageAlreadyPresent(this.messages, json.message)) {
this.messages.push(json.message);

diff --git a/meshchatx/src/frontend/components/relay/RelayHostModerationPage.vue b/meshchatx/src/frontend/components/relay/RelayHostModerationPage.vue
index 765afeed..f42a0bc6 100644
--- a/meshchatx/src/frontend/components/relay/RelayHostModerationPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayHostModerationPage.vue
@@ -102,6 +102,13 @@
:placeholder="$t('relay_chat.host_room_topic')"
class="input-field w-full !py-2.5 !text-sm"
/>
+ <input
+ v-model="newRoom.key"
+ type="password"
+ :placeholder="$t('relay_chat.host_room_key_placeholder')"
+ autocomplete="off"
+ class="input-field w-full !py-2.5 !text-sm"
+ />
<div class="flex gap-2 pt-0.5">
<button
type="submit"
@@ -146,7 +153,19 @@
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
- <div class="font-medium text-sem-fg">#{{ room.name }}</div>
+ <div class="flex items-center gap-1.5 font-medium text-sem-fg">
+ <span>#{{ room.name }}</span>
+ <span
+ v-if="room.has_key"
+ class="inline-flex"
+ :title="$t('relay_chat.host_room_keyed')"
+ >
+ <MaterialDesignIcon
+ icon-name="lock"
+ class="size-3.5 shrink-0 text-sem-fg-muted"
+ />
+ </span>
+ </div>
<div v-if="room.topic" class="truncate text-xs text-sem-fg-muted">
{{ room.topic }}
</div>
@@ -192,6 +211,37 @@
<div class="font-semibold text-sem-fg">#{{ selectedRoom }}</div>
<div class="text-xs text-sem-fg-muted">{{ $t("relay_chat.host_room_activity") }}</div>
</div>
+ <div
+ v-if="selectedRoomObj"
+ class="flex flex-wrap items-center gap-2 border-b border-sem-border px-3 py-2 text-xs text-sem-fg-muted"
+ >
+ <MaterialDesignIcon
+ :icon-name="selectedRoomObj.has_key ? 'lock' : 'lock-open-variant'"
+ class="size-4 shrink-0"
+ />
+ <span class="flex-1">
+ {{
+ selectedRoomObj.has_key
+ ? $t("relay_chat.host_room_keyed")
+ : $t("relay_chat.host_room_unkeyed")
+ }}
+ </span>
+ <button type="button" class="font-medium text-sem-accent hover:underline" @click="setRoomKey">
+ {{
+ selectedRoomObj.has_key
+ ? $t("relay_chat.host_change_room_key")
+ : $t("relay_chat.host_set_room_key")
+ }}
+ </button>
+ <button
+ v-if="selectedRoomObj.has_key"
+ type="button"
+ class="font-medium text-sem-danger hover:underline"
+ @click="clearRoomKey"
+ >
+ {{ $t("relay_chat.host_clear_room_key") }}
+ </button>
+ </div>
<div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<ul v-if="roomMessages.length > 0" class="space-y-2">
<li v-for="(msg, idx) in roomMessages" :key="idx" :class="RELAY_HOST_MESSAGE">
@@ -414,7 +464,7 @@ export default {
selectedRoom: null,
selectedMember: null,
memberMessages: [],
- newRoom: { name: "", topic: "" },
+ newRoom: { name: "", topic: "", key: "" },
localIdentityHash: "",
uptimeTick: 0,
uptimeAnchorMs: 0,
@@ -459,6 +509,12 @@ export default {
return name.includes(q) || topic.includes(q);
});
},
+ selectedRoomObj() {
+ if (!this.selectedRoom) {
+ return null;
+ }
+ return this.rooms.find((room) => room.name === this.selectedRoom) || null;
+ },
pageTitle() {
if (!this.hub?.name) {
return this.$t("relay_chat.host_moderation_title");
@@ -552,7 +608,7 @@ export default {
this.selectedRoom = null;
this.selectedMember = null;
this.memberMessages = [];
- this.newRoom = { name: "", topic: "" };
+ this.newRoom = { name: "", topic: "", key: "" };
this.showAddRoomForm = false;
this.roomsSearch = "";
if (this.hub?.running) {
@@ -566,7 +622,7 @@ export default {
},
cancelAddRoom() {
this.showAddRoomForm = false;
- this.newRoom = { name: "", topic: "" };
+ this.newRoom = { name: "", topic: "", key: "" };
},
async fetchActivity() {
if (!this.hub?.id) {
@@ -609,8 +665,9 @@ export default {
await window.api.post(`/api/v1/rrc/servers/${this.hub.id}/rooms`, {
name,
topic: (this.newRoom.topic || "").trim() || undefined,
+ key: (this.newRoom.key || "").trim() || undefined,
});
- this.newRoom = { name: "", topic: "" };
+ this.newRoom = { name: "", topic: "", key: "" };
this.showAddRoomForm = false;
ToastUtils.success(this.$t("relay_chat.host_room_created"));
this.$emit("refresh");
@@ -621,6 +678,55 @@ export default {
this.creatingRoom = false;
}
},
+ async setRoomKey() {
+ if (!this.hub?.id || !this.selectedRoom) {
+ return;
+ }
+ const entered = await DialogUtils.prompt(
+ this.$t("relay_chat.host_room_key_prompt", { room: this.selectedRoom }),
+ "",
+ { inputType: "password" }
+ );
+ if (entered == null) {
+ return;
+ }
+ const key = String(entered).trim();
+ if (!key) {
+ ToastUtils.warning(this.$t("relay_chat.host_room_key_required"));
+ return;
+ }
+ try {
+ await window.api.put(
+ `/api/v1/rrc/servers/${this.hub.id}/rooms/${encodeURIComponent(this.selectedRoom)}/key`,
+ { key }
+ );
+ ToastUtils.success(this.$t("relay_chat.host_room_key_saved"));
+ this.$emit("refresh");
+ await this.fetchActivity();
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ }
+ },
+ async clearRoomKey() {
+ if (!this.hub?.id || !this.selectedRoom) {
+ return;
+ }
+ const confirmed = await DialogUtils.confirm(this.$t("relay_chat.host_clear_room_key_confirm"));
+ if (!confirmed) {
+ return;
+ }
+ try {
+ await window.api.put(
+ `/api/v1/rrc/servers/${this.hub.id}/rooms/${encodeURIComponent(this.selectedRoom)}/key`,
+ { key: null }
+ );
+ ToastUtils.success(this.$t("relay_chat.host_room_key_cleared"));
+ this.$emit("refresh");
+ await this.fetchActivity();
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ }
+ },
async deleteRoom(room) {
const confirmed = await DialogUtils.confirm(this.$t("relay_chat.host_delete_room_confirm"));
if (!confirmed || !this.hub?.id) {

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 60846dd1..2729b45f 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -3699,10 +3699,7 @@
{{ $t("app.propagation_stamp_description") }}
</div>
</div>
- <label
- v-if="config.lxmf_local_propagation_node_enabled"
- class="setting-toggle"
- >
+ <label v-if="config.lxmf_local_propagation_node_enabled" class="setting-toggle">
<Toggle
id="propagation-sequential-validation"
v-model="config.lxmf_propagation_sequential_validation"
@@ -3717,10 +3714,7 @@
}}</span>
</span>
</label>
- <label
- v-if="config.lxmf_local_propagation_node_enabled"
- class="setting-toggle"
- >
+ <label v-if="config.lxmf_local_propagation_node_enabled" class="setting-toggle">
<Toggle
id="propagation-static-peers-bypass-sequential"
v-model="config.lxmf_propagation_static_peers_bypass_sequential"

diff --git a/meshchatx/src/frontend/js/DialogUtils.js b/meshchatx/src/frontend/js/DialogUtils.js
index 666ed77a..6ed2fed5 100644
--- a/meshchatx/src/frontend/js/DialogUtils.js
+++ b/meshchatx/src/frontend/js/DialogUtils.js
@@ -31,8 +31,10 @@ class DialogUtils {
});
}
- static async prompt(message, defaultValue = "") {
- if (window.electron && typeof window.electron.prompt === "function") {
+ static async prompt(message, defaultValue = "", options = {}) {
+ const inputType =
+ options && typeof options === "object" && options.inputType ? String(options.inputType) : "text";
+ if (window.electron && typeof window.electron.prompt === "function" && inputType === "text") {
try {
return await window.electron.prompt(message, defaultValue);
} catch {
@@ -43,6 +45,7 @@ class DialogUtils {
GlobalEmitter.emit("prompt", {
message,
defaultValue: defaultValue == null ? "" : String(defaultValue),
+ inputType,
resolve,
});
});

diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index 73b25abb..054b27af 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -114,6 +114,11 @@ export default class MicronParser extends BaseMicronParser {
if (dangerousProps.includes(prop)) return false;
if (prop === "width" && (/100v[wh]/.test(val) || /^100%$/.test(val))) return false;
if (prop === "height" && (/100v[hw]/.test(val) || /^100%$/.test(val))) return false;
+ // Block clearnet / protocol-relative url() and @import in inline styles.
+ if (/url\s*\(\s*["']?(?:https?:|\/\/)/i.test(decl)) return false;
+ if (/@import/i.test(decl)) return false;
+ if (/expression\s*\(/i.test(decl)) return false;
+ if (/javascript\s*:/i.test(decl)) return false;
return true;
});
return safe.join("; ").trim();
@@ -129,22 +134,45 @@ export default class MicronParser extends BaseMicronParser {
return out;
}
+ /**
+ * Scrub network CSS from style tag bodies (WASM micron path).
+ * Kept local to avoid a circular import with NomadPageRenderer.
+ */
+ static scrubNetworkCss(css) {
+ if (!css) {
+ return "";
+ }
+ let s = String(css);
+ s = s.replace(/@import\s+[^;]+;/gi, "");
+ s = s.replace(/@import\s+url\s*\([^)]+\)\s*;?/gi, "");
+ s = s.replace(/expression\s*\(/gi, "blocked(");
+ s = s.replace(/javascript\s*:/gi, "blocked:");
+ s = s.replace(/-moz-binding/gi, "blocked-binding");
+ s = s.replace(/url\s*\(\s*["']?(?:https?:|\/\/)/gi, "url(blocked:");
+ return s;
+ }
+
static sanitizeRenderedMicronHtml(html) {
if (html == null) {
return "";
}
const s = typeof html === "string" ? html : String(html);
try {
- const sanitized = DOMPurify.sanitize(s, {
+ let sanitized = DOMPurify.sanitize(s, {
USE_PROFILES: { html: true },
ALLOWED_URI_REGEXP,
});
try {
- return MicronParser.stripOverlayStyles(sanitized);
+ sanitized = MicronParser.stripOverlayStyles(sanitized);
} catch (e) {
console.warn("MicronParser: stripOverlayStyles failed", e);
- return sanitized;
}
+ try {
+ sanitized = MicronParser.scrubNetworkCssInStyleTags(sanitized);
+ } catch (e) {
+ console.warn("MicronParser: scrubNetworkCss failed", e);
+ }
+ return sanitized;
} catch (error) {
console.warn(
"DOMPurify is not installed or sanitization failed. Include dompurify or check the build.",
@@ -154,6 +182,40 @@ export default class MicronParser extends BaseMicronParser {
}
}
+ /**
+ * Scrub network CSS inside &lt;style&gt; blocks without nested regex backtracking.
+ */
+ static scrubNetworkCssInStyleTags(html) {
+ if (typeof html !== "string" || !html) {
+ return html == null ? "" : html;
+ }
+ const lower = html.toLowerCase();
+ let out = "";
+ let i = 0;
+ while (i < html.length) {
+ const openIdx = lower.indexOf("<style", i);
+ if (openIdx < 0) {
+ out += html.slice(i);
+ break;
+ }
+ const tagEnd = html.indexOf(">", openIdx);
+ if (tagEnd < 0) {
+ out += html.slice(i);
+ break;
+ }
+ const closeIdx = lower.indexOf("</style>", tagEnd + 1);
+ if (closeIdx < 0) {
+ out += html.slice(i);
+ break;
+ }
+ out += html.slice(i, tagEnd + 1);
+ out += MicronParser.scrubNetworkCss(html.slice(tagEnd + 1, closeIdx));
+ out += html.slice(closeIdx, closeIdx + "</style>".length);
+ i = closeIdx + "</style>".length;
+ }
+ return out;
+ }
+
/**
* Split Micron source into blocks for WASM conversion, keeping MeshChat partial-include lines on the JS path.
*/

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index b884993e..d328feee 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -3696,7 +3696,22 @@
"presence_joined": "{count} beigetreten",
"presence_left": "{count} verlassen",
"presence_events": "{count} Beitritts-/Austrittsereignisse",
- "presence_connection": "{count} Verbindungsereignisse"
+ "presence_connection": "{count} Verbindungsereignisse",
+ "join_room_key_placeholder": "Raumschlüssel (falls nötig)",
+ "room_key_prompt": "Schlüssel für #{room} eingeben",
+ "bad_key": "Falscher Raumschlüssel. Bitte den richtigen Schlüssel eingeben.",
+ "join_requested": "Beitrittsanfrage gesendet",
+ "host_room_key_placeholder": "Raumschlüssel (optional)",
+ "host_room_keyed": "Dieser Raum erfordert einen Schlüssel",
+ "host_room_unkeyed": "Dieser Raum hat keinen Schlüssel",
+ "host_set_room_key": "Schlüssel setzen",
+ "host_change_room_key": "Schlüssel ändern",
+ "host_clear_room_key": "Schlüssel entfernen",
+ "host_room_key_prompt": "Neuen Schlüssel für #{room} eingeben",
+ "host_room_key_required": "Ein Raumschlüssel ist erforderlich",
+ "host_room_key_saved": "Raumschlüssel gespeichert",
+ "host_clear_room_key_confirm": "Schlüssel aus diesem Raum entfernen? Dann kann jeder beitreten.",
+ "host_room_key_cleared": "Raumschlüssel entfernt"
},
"android_storage": {
"setup_title": "Wählen Sie, wo MeshChatX Daten speichert",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 6b46abdb..f9697d5a 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -2858,6 +2858,10 @@
"members": "Members",
"join_room": "Join Room",
"join_room_placeholder": "room name",
+ "join_room_key_placeholder": "Room key (if required)",
+ "room_key_prompt": "Enter the key for #{room}",
+ "bad_key": "Wrong room key. Enter the correct key to join.",
+ "join_requested": "Join request sent",
"leave_room": "Leave Room",
"clear_messages": "Clear Messages",
"connect": "Connect",
@@ -2928,6 +2932,17 @@
"host_delete_hub_confirm": "Delete this hosted hub? Connected clients will be disconnected.",
"host_hub_deleted": "Hub deleted",
"host_room_created": "Room created",
+ "host_room_key_placeholder": "Room key (optional)",
+ "host_room_keyed": "This room requires a key",
+ "host_room_unkeyed": "This room has no key",
+ "host_set_room_key": "Set key",
+ "host_change_room_key": "Change key",
+ "host_clear_room_key": "Clear key",
+ "host_room_key_prompt": "Enter a new key for #{room}",
+ "host_room_key_required": "A room key is required",
+ "host_room_key_saved": "Room key saved",
+ "host_clear_room_key_confirm": "Remove the key from this room? Anyone will be able to join.",
+ "host_room_key_cleared": "Room key cleared",
"host_delete_room_confirm": "Delete this room?",
"host_room_deleted": "Room deleted",
"host_members_all_title": "Connected clients: {hub}",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index a01eeb60..ba53d635 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -3511,7 +3511,7 @@
"relay_chat": {
"title": "Chat de retransmision",
"subtitle": "Hubs y salas de Reticulum Relay Chat",
- "hubs": "Hubs",
+ "hubs": "Concentradores",
"add_hub": "Anadir hub",
"add_hub_title": "Anadir un hub de chat de retransmision",
"hub_hash": "Hash de destino del hub",
@@ -3696,7 +3696,22 @@
"presence_joined": "{count} se unieron",
"presence_left": "{count} salieron",
"presence_events": "{count} eventos de entrada/salida",
- "presence_connection": "{count} eventos de conexión"
+ "presence_connection": "{count} eventos de conexión",
+ "join_room_key_placeholder": "Clave de sala (si se requiere)",
+ "room_key_prompt": "Introduce la clave de #{room}",
+ "bad_key": "Clave de sala incorrecta. Introduce la clave correcta para unirte.",
+ "join_requested": "Solicitud de unión enviada",
+ "host_room_key_placeholder": "Clave de sala (opcional)",
+ "host_room_keyed": "Esta sala requiere una clave",
+ "host_room_unkeyed": "Esta sala no tiene clave",
+ "host_set_room_key": "Establecer clave",
+ "host_change_room_key": "Cambiar clave",
+ "host_clear_room_key": "Quitar clave",
+ "host_room_key_prompt": "Introduce una nueva clave para #{room}",
+ "host_room_key_required": "Se requiere una clave de sala",
+ "host_room_key_saved": "Clave de sala guardada",
+ "host_clear_room_key_confirm": "¿Quitar la clave de esta sala? Cualquiera podrá unirse.",
+ "host_room_key_cleared": "Clave de sala eliminada"
},
"android_storage": {
"setup_title": "Elige dónde almacena datos MeshChatX",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 8bd86d43..2e37269f 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -3024,7 +3024,22 @@
"presence_joined": "{count} liittyi",
"presence_left": "{count} lähti",
"presence_events": "{count} liittymis-/lähtötapahtumaa",
- "presence_connection": "{count} yhteystapahtumaa"
+ "presence_connection": "{count} yhteystapahtumaa",
+ "join_room_key_placeholder": "Huoneen avain (jos tarvitaan)",
+ "room_key_prompt": "Anna avain huoneelle #{room}",
+ "bad_key": "Väärä huoneen avain. Anna oikea avain liittyäksesi.",
+ "join_requested": "Liittymispyyntö lähetetty",
+ "host_room_key_placeholder": "Huoneen avain (valinnainen)",
+ "host_room_keyed": "Tämä huone vaatii avaimen",
+ "host_room_unkeyed": "Tällä huoneella ei ole avainta",
+ "host_set_room_key": "Aseta avain",
+ "host_change_room_key": "Vaihda avain",
+ "host_clear_room_key": "Poista avain",
+ "host_room_key_prompt": "Anna uusi avain huoneelle #{room}",
+ "host_room_key_required": "Huoneen avain vaaditaan",
+ "host_room_key_saved": "Huoneen avain tallennettu",
+ "host_clear_room_key_confirm": "Poistetaanko avain tästä huoneesta? Kuka tahansa voi liittyä.",
+ "host_room_key_cleared": "Huoneen avain poistettu"
},
"rncp": {
"file_transfer": "Tiedostonsiirto",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index d5ef9fbf..a832faf0 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -3696,7 +3696,22 @@
"presence_joined": "{count} ont rejoint",
"presence_left": "{count} sont partis",
"presence_events": "{count} événements entrée/sortie",
- "presence_connection": "{count} événements de connexion"
+ "presence_connection": "{count} événements de connexion",
+ "join_room_key_placeholder": "Clé de salon (si requise)",
+ "room_key_prompt": "Entrez la clé pour #{room}",
+ "bad_key": "Mauvaise clé de salon. Entrez la bonne clé pour rejoindre.",
+ "join_requested": "Demande d'adhésion envoyée",
+ "host_room_key_placeholder": "Clé de salon (facultative)",
+ "host_room_keyed": "Ce salon nécessite une clé",
+ "host_room_unkeyed": "Ce salon n'a pas de clé",
+ "host_set_room_key": "Définir la clé",
+ "host_change_room_key": "Modifier la clé",
+ "host_clear_room_key": "Supprimer la clé",
+ "host_room_key_prompt": "Entrez une nouvelle clé pour #{room}",
+ "host_room_key_required": "Une clé de salon est requise",
+ "host_room_key_saved": "Clé de salon enregistrée",
+ "host_clear_room_key_confirm": "Supprimer la clé de ce salon ? N'importe qui pourra rejoindre.",
+ "host_room_key_cleared": "Clé de salon supprimée"
},
"android_storage": {
"setup_title": "Choisissez où MeshChatX stocke les données",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index e1cfa005..31f92c02 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -3696,7 +3696,22 @@
"presence_joined": "{count} entrati",
"presence_left": "{count} usciti",
"presence_events": "{count} eventi entrata/uscita",
- "presence_connection": "{count} eventi di connessione"
+ "presence_connection": "{count} eventi di connessione",
+ "join_room_key_placeholder": "Chiave della stanza (se richiesta)",
+ "room_key_prompt": "Inserisci la chiave per #{room}",
+ "bad_key": "Chiave della stanza errata. Inserisci la chiave corretta per entrare.",
+ "join_requested": "Richiesta di accesso inviata",
+ "host_room_key_placeholder": "Chiave della stanza (facoltativa)",
+ "host_room_keyed": "Questa stanza richiede una chiave",
+ "host_room_unkeyed": "Questa stanza non ha una chiave",
+ "host_set_room_key": "Imposta chiave",
+ "host_change_room_key": "Cambia chiave",
+ "host_clear_room_key": "Rimuovi chiave",
+ "host_room_key_prompt": "Inserisci una nuova chiave per #{room}",
+ "host_room_key_required": "È richiesta una chiave della stanza",
+ "host_room_key_saved": "Chiave della stanza salvata",
+ "host_clear_room_key_confirm": "Rimuovere la chiave da questa stanza? Chiunque potrà entrare.",
+ "host_room_key_cleared": "Chiave della stanza rimossa"
},
"android_storage": {
"setup_title": "Scegli dove MeshChatX archivia i dati",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index b20653cf..14375356 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -3696,7 +3696,22 @@
"presence_joined": "{count} toegetreden",
"presence_left": "{count} vertrokken",
"presence_events": "{count} join/leave-gebeurtenissen",
- "presence_connection": "{count} verbindingsgebeurtenissen"
+ "presence_connection": "{count} verbindingsgebeurtenissen",
+ "join_room_key_placeholder": "Kamersleutel (indien vereist)",
+ "room_key_prompt": "Voer de sleutel in voor #{room}",
+ "bad_key": "Verkeerde kamersleutel. Voer de juiste sleutel in om toe te treden.",
+ "join_requested": "Toetredingsverzoek verzonden",
+ "host_room_key_placeholder": "Kamersleutel (optioneel)",
+ "host_room_keyed": "Deze kamer vereist een sleutel",
+ "host_room_unkeyed": "Deze kamer heeft geen sleutel",
+ "host_set_room_key": "Sleutel instellen",
+ "host_change_room_key": "Sleutel wijzigen",
+ "host_clear_room_key": "Sleutel wissen",
+ "host_room_key_prompt": "Voer een nieuwe sleutel in voor #{room}",
+ "host_room_key_required": "Een kamersleutel is vereist",
+ "host_room_key_saved": "Kamersleutel opgeslagen",
+ "host_clear_room_key_confirm": "Sleutel van deze kamer verwijderen? Iedereen kan dan toetreden.",
+ "host_room_key_cleared": "Kamersleutel verwijderd"
},
"android_storage": {
"setup_title": "Kies waar MeshChatX gegevens opslaat",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 98d4e039..2ef283cc 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -3696,7 +3696,22 @@
"presence_joined": "{count} вошли",
"presence_left": "{count} вышли",
"presence_events": "{count} событий входа/выхода",
- "presence_connection": "{count} событий соединения"
+ "presence_connection": "{count} событий соединения",
+ "join_room_key_placeholder": "Ключ комнаты (если нужен)",
+ "room_key_prompt": "Введите ключ для #{room}",
+ "bad_key": "Неверный ключ комнаты. Введите правильный ключ, чтобы войти.",
+ "join_requested": "Запрос на вход отправлен",
+ "host_room_key_placeholder": "Ключ комнаты (необязательно)",
+ "host_room_keyed": "Для этой комнаты нужен ключ",
+ "host_room_unkeyed": "У этой комнаты нет ключа",
+ "host_set_room_key": "Установить ключ",
+ "host_change_room_key": "Изменить ключ",
+ "host_clear_room_key": "Удалить ключ",
+ "host_room_key_prompt": "Введите новый ключ для #{room}",
+ "host_room_key_required": "Требуется ключ комнаты",
+ "host_room_key_saved": "Ключ комнаты сохранён",
+ "host_clear_room_key_confirm": "Удалить ключ из этой комнаты? Войти сможет кто угодно.",
+ "host_room_key_cleared": "Ключ комнаты удалён"
},
"android_storage": {
"setup_title": "Выберите, где MeshChatX хранит данные",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index a9357b66..b386af96 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -3696,7 +3696,22 @@
"presence_joined": "{count} 人加入",
"presence_left": "{count} 人离开",
"presence_events": "{count} 条加入/离开事件",
- "presence_connection": "{count} 条连接事件"
+ "presence_connection": "{count} 条连接事件",
+ "join_room_key_placeholder": "房间密钥(如需要)",
+ "room_key_prompt": "请输入 #{room} 的密钥",
+ "bad_key": "房间密钥错误。请输入正确密钥以加入。",
+ "join_requested": "已发送加入请求",
+ "host_room_key_placeholder": "房间密钥(可选)",
+ "host_room_keyed": "此房间需要密钥",
+ "host_room_unkeyed": "此房间没有密钥",
+ "host_set_room_key": "设置密钥",
+ "host_change_room_key": "更改密钥",
+ "host_clear_room_key": "清除密钥",
+ "host_room_key_prompt": "请为 #{room} 输入新密钥",
+ "host_room_key_required": "需要房间密钥",
+ "host_room_key_saved": "房间密钥已保存",
+ "host_clear_room_key_confirm": "移除此房间的密钥?之后任何人都可以加入。",
+ "host_room_key_cleared": "房间密钥已清除"
},
"android_storage": {
"setup_title": "选择MeshChatX存储数据的位置",

diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py
index dbd94c2b..91f2dd71 100644
--- a/tests/backend/conftest.py
+++ b/tests/backend/conftest.py
@@ -286,6 +286,8 @@ def mock_app(db, tmp_path, temp_db):
app.database = Database(temp_db)
app.current_context.config = ConfigManager(app.database)
app.config = app.current_context.config
+ if app.rrc_manager is not None:
+ app.rrc_manager.set_database(app.database)
app.websocket_broadcast = MagicMock(side_effect=lambda data: None)
yield app

diff --git a/tests/backend/test_database_lifecycle_safety.py b/tests/backend/test_database_lifecycle_safety.py
index 0f080573..5e3f79d3 100644
--- a/tests/backend/test_database_lifecycle_safety.py
+++ b/tests/backend/test_database_lifecycle_safety.py
@@ -239,6 +239,31 @@ def test_restore_rejects_non_sqlite_backup(temp_dir):
db.close_all()
+@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
+def test_safe_zip_extract_rejects_symlink_jail_escape(temp_dir):
+ """Restore must not follow identity-dir symlinks outside the jail."""
+ import zipfile
+
+ identity_dir = os.path.join(temp_dir, "identity")
+ outside = os.path.join(temp_dir, "OUTSIDE")
+ os.makedirs(identity_dir)
+ os.makedirs(outside)
+ link = os.path.join(identity_dir, "plugins")
+ os.symlink(outside, link)
+
+ zip_path = os.path.join(temp_dir, "evil.zip")
+ with zipfile.ZipFile(zip_path, "w") as zf:
+ zf.writestr("plugins/stolen_write.txt", b"pwned")
+
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ with pytest.raises(DatabaseRestoreError, match="Unsafe zip entry"):
+ Database._safe_zip_extract_member(
+ zf, "plugins/stolen_write.txt", identity_dir
+ )
+
+ assert not os.path.exists(os.path.join(outside, "stolen_write.txt"))
+
+
def test_looks_like_sqlite_header():
path = tempfile.NamedTemporaryFile(delete=False).name
try:

diff --git a/tests/backend/test_identity_switch.py b/tests/backend/test_identity_switch.py
index dde5803b..a2cccda9 100644
--- a/tests/backend/test_identity_switch.py
+++ b/tests/backend/test_identity_switch.py
@@ -175,6 +175,7 @@ async def test_hotswap_identity_keep_alive(mock_rns, temp_dir):
# Mock methods
app.teardown_identity = MagicMock()
+ app._clear_mesh_link_caches = MagicMock()
app.setup_identity = MagicMock(
side_effect=lambda id: setattr(app, "current_context", mock_context),
)
@@ -185,6 +186,7 @@ async def test_hotswap_identity_keep_alive(mock_rns, temp_dir):
assert result is True
app.teardown_identity.assert_not_called()
+ app._clear_mesh_link_caches.assert_called_once()
app.setup_identity.assert_called_once_with(new_id_instance)

diff --git a/tests/backend/test_lxmf_stamp_enforcement_wiring.py b/tests/backend/test_lxmf_stamp_enforcement_wiring.py
new file mode 100644
index 00000000..306f4c99
--- /dev/null
+++ b/tests/backend/test_lxmf_stamp_enforcement_wiring.py
@@ -0,0 +1,53 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Regression: inbound stamp cost must enable LXMF stamp enforcement."""
+
+from unittest.mock import MagicMock
+
+from meshchatx.src.backend.identity_context import IdentityContext
+
+
+def test_identity_context_enforces_stamps_when_inbound_cost_positive():
+ app = MagicMock()
+ app.storage_dir = "/tmp/unused"
+ identity = MagicMock()
+ identity.hash = b"\x11" * 16
+
+ ctx = IdentityContext.__new__(IdentityContext)
+ ctx.app = app
+ ctx.identity = identity
+ ctx.identity_hash = identity.hash.hex()
+ ctx.config = MagicMock()
+ ctx.config.block_all_from_strangers.get.return_value = False
+ ctx.config.lxmf_inbound_stamp_cost.get.return_value = 8
+ ctx.config.display_name.get.return_value = "Test"
+ ctx.message_router = MagicMock()
+ dest = MagicMock()
+ dest.hash = b"\x22" * 16
+ ctx.message_router.register_delivery_identity.return_value = dest
+
+ # Run only the stamp wiring fragment used after router creation.
+ inbound_stamp_cost = ctx.config.lxmf_inbound_stamp_cost.get()
+ ctx.local_lxmf_destination = ctx.message_router.register_delivery_identity(
+ identity=ctx.identity,
+ display_name=ctx.config.display_name.get(),
+ stamp_cost=inbound_stamp_cost,
+ )
+ if isinstance(inbound_stamp_cost, int) and inbound_stamp_cost > 0:
+ ctx.message_router.enforce_stamps()
+ elif hasattr(ctx.message_router, "ignore_stamps"):
+ ctx.message_router.ignore_stamps()
+
+ ctx.message_router.enforce_stamps.assert_called_once()
+ ctx.message_router.ignore_stamps.assert_not_called()
+
+
+def test_identity_context_ignores_stamps_when_inbound_cost_zero():
+ ctx_router = MagicMock()
+ inbound_stamp_cost = 0
+ if isinstance(inbound_stamp_cost, int) and inbound_stamp_cost > 0:
+ ctx_router.enforce_stamps()
+ elif hasattr(ctx_router, "ignore_stamps"):
+ ctx_router.ignore_stamps()
+ ctx_router.ignore_stamps.assert_called_once()
+ ctx_router.enforce_stamps.assert_not_called()

diff --git a/tests/backend/test_nomadnet_downloader.py b/tests/backend/test_nomadnet_downloader.py
index d4253fcf..a9ca6e89 100644
--- a/tests/backend/test_nomadnet_downloader.py
+++ b/tests/backend/test_nomadnet_downloader.py
@@ -187,3 +187,34 @@ def test_nomad_link_cache_evicts_over_cap():
assert get_cached_active_link(links[2][0]) is links[2][1]
finally:
nd.MAX_CACHED_LINKS = original_max
+
+
+def test_clear_all_nomadnet_cached_links_tears_down_active():
+ from meshchatx.src.backend.nomadnet_downloader import (
+ clear_all_nomadnet_cached_links,
+ )
+
+ link = MagicMock()
+ link.status = RNS.Link.ACTIVE
+ with _nomadnet_links_lock:
+ nomadnet_cached_links[b"x" * 16] = link
+ assert clear_all_nomadnet_cached_links() == 1
+ assert get_cached_active_link(b"x" * 16) is None
+ link.teardown.assert_called_once()
+
+
+def test_file_downloader_sanitizes_fallback_name():
+ on_ok = MagicMock()
+ on_fail = MagicMock()
+ fd = NomadnetFileDownloader(
+ b"ab" * 8,
+ "/f.bin",
+ on_ok,
+ on_fail,
+ MagicMock(),
+ )
+ rr = MagicMock()
+ rr.response = ["../../etc/passwd", b"ok"]
+ fd.on_download_success(rr)
+ on_ok.assert_called_once_with("passwd", b"ok")
+ on_fail.assert_not_called()

diff --git a/tests/backend/test_plugin_integrity.py b/tests/backend/test_plugin_integrity.py
index 933e67c1..dc0a9442 100644
--- a/tests/backend/test_plugin_integrity.py
+++ b/tests/backend/test_plugin_integrity.py
@@ -27,7 +27,9 @@ def _install_simple(manager, tmp_path):
return manager.install_from_directory(str(source))
-def test_tamper_disables_and_reenable_refreshes_hash(tmp_path):
+def test_tamper_disables_and_reenable_refuses_without_reinstall(tmp_path):
+ from meshchatx.src.backend.plugin_guard import PluginSecurityError
+
manager = PluginManager(str(tmp_path / "storage"))
installed = _install_simple(manager, tmp_path)
plugin_id = installed["id"]
@@ -52,6 +54,11 @@ def test_tamper_disables_and_reenable_refreshes_hash(tmp_path):
assert view["tampered"] is True
assert INTEGRITY_TAMPER_MESSAGE in (view["auto_disabled_reason"] or "")
- enabled = reloaded.enable(plugin_id)
- assert enabled["enabled"] is True
- assert enabled["tampered"] is False
+ try:
+ reloaded.enable(plugin_id)
+ raise AssertionError("enable must refuse tampered plugin trees")
+ except PluginSecurityError as exc:
+ assert "tampered" in str(exc).lower()
+ still = reloaded.get_plugin(plugin_id)
+ assert still["enabled"] is False
+ assert still["tampered"] is True

diff --git a/tests/backend/test_rns_link_manager.py b/tests/backend/test_rns_link_manager.py
index f17e4e75..5843028b 100644
--- a/tests/backend/test_rns_link_manager.py
+++ b/tests/backend/test_rns_link_manager.py
@@ -96,6 +96,27 @@ def test_clear_all_cached_links_tears_down_active():
assert rlm._link_failure_counts == {}
+def test_clear_mesh_link_caches_clears_rns_and_nomad(monkeypatch):
+ from meshchatx.meshchat import ReticulumMeshChat
+ from meshchatx.src.backend import nomadnet_downloader as nd
+
+ rns_cleared = []
+ nomad_cleared = []
+ monkeypatch.setattr(
+ "meshchatx.meshchat.clear_all_cached_links",
+ lambda: rns_cleared.append(1) or 1,
+ )
+ monkeypatch.setattr(
+ "meshchatx.meshchat.clear_all_nomadnet_cached_links",
+ lambda: nomad_cleared.append(1) or 1,
+ )
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app._clear_mesh_link_caches()
+ assert rns_cleared == [1]
+ assert nomad_cleared == [1]
+ assert nd is not None
+
+
@pytest.mark.asyncio
async def test_open_link_reuses_cached_active():
dest = bytes.fromhex("bb" * 16)

diff --git a/tests/backend/test_rrc_oracle_bugs.py b/tests/backend/test_rrc_oracle_bugs.py
new file mode 100644
index 00000000..2874e693
--- /dev/null
+++ b/tests/backend/test_rrc_oracle_bugs.py
@@ -0,0 +1,495 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Oracle and exploratory tests for RRC hub/client membership and ACL bugs."""
+
+from __future__ import annotations
+
+import os
+import time
+
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from meshchatx.src.backend.rrc import protocol as proto
+from meshchatx.src.backend.rrc.manager import RRCManager
+from meshchatx.src.backend.rrc.rooms_toml import (
+ dump_rooms_registry,
+ load_rooms_registry,
+)
+from meshchatx.src.backend.rrc.server import RRCHubServer, _Session
+
+HUB_HASH = bytes(range(16))
+
+
+class FakeIdentity:
+ def __init__(self, hash_bytes):
+ self.hash = hash_bytes
+
+
+class FakeLink:
+ def __init__(self, identity):
+ self._identity = identity
+
+ def get_remote_identity(self):
+ return self._identity
+
+
+class FakeManager:
+ def __init__(self):
+ self.identity = FakeIdentity(b"\x22" * 16)
+ self.history_per_room_cap = 0
+ self.filter_loaded_history = False
+ self._active_hub = None
+ self._active_room = None
+ self.saved = 0
+
+ def get_nickname(self):
+ return None
+
+ def get_name_for_identity_hash(self, _h):
+ return None
+
+ def save(self):
+ self.saved += 1
+
+ def _notify_change(self, hub=None):
+ pass
+
+ def _notify_messages(self, hub, msg):
+ pass
+
+ def set_active(self, hub, room):
+ self._active_hub = hub
+ self._active_room = room
+
+ def active_room_for(self, hub):
+ if self._active_hub is hub:
+ return self._active_room
+ return None
+
+ def _on_welcome(self, hub):
+ pass
+
+ def find_local_server(self, _h):
+ return None
+
+ def is_bad_key_error(self, text):
+ return RRCManager.is_bad_key_error(text)
+
+ def forget_room_key(self, hub, room):
+ return 0
+
+
+def make_server(**kwargs):
+ return RRCHubServer(
+ FakeManager(),
+ FakeIdentity(HUB_HASH),
+ name="Oracle Hub",
+ **kwargs,
+ )
+
+
+def add_session(server, peer_hash, nick=None, welcomed=True):
+ link = FakeLink(FakeIdentity(peer_hash))
+ sess = _Session()
+ sess.peer = peer_hash
+ sess.nick = nick
+ sess.welcomed = welcomed
+ server._sessions[link] = sess
+ return link, sess
+
+
+def route(server, link, sess, env):
+ outgoing = []
+ server._route(link, sess, env, outgoing)
+ return [(out_link, proto.decode(payload)) for out_link, payload in outgoing]
+
+
+def join(server, link, sess, room, key=None):
+ return route(
+ server,
+ link,
+ sess,
+ proto.make_envelope(proto.T_JOIN, src=sess.peer, room=room, body=key),
+ )
+
+
+def part(server, link, sess, room):
+ return route(
+ server,
+ link,
+ sess,
+ proto.make_envelope(proto.T_PART, src=sess.peer, room=room),
+ )
+
+
+def msg(server, link, sess, room, body):
+ return route(
+ server,
+ link,
+ sess,
+ proto.make_envelope(proto.T_MSG, src=sess.peer, room=room, body=body),
+ )
+
+
+def envs_of_type(out, msg_type, to_link=None):
+ result = []
+ for lnk, env in out:
+ if to_link is not None and lnk is not to_link:
+ continue
+ if env.get(proto.K_T) == msg_type:
+ result.append(env)
+ return result
+
+
+def test_oracle_phantom_part_does_not_fanout():
+ """Non-member PART must not tell real members that someone left."""
+ server = make_server()
+ link_a, sess_a = add_session(server, b"\xaa" * 16, nick="alice")
+ link_b, sess_b = add_session(server, b"\xbb" * 16, nick="bob")
+ link_c, sess_c = add_session(server, b"\xcc" * 16, nick="carol")
+ join(server, link_a, sess_a, "lobby")
+ join(server, link_b, sess_b, "lobby")
+
+ out = part(server, link_c, sess_c, "lobby")
+ parted_to_members = [
+ env
+ for lnk, env in out
+ if lnk in (link_a, link_b) and env.get(proto.K_T) == proto.T_PARTED
+ ]
+ assert parted_to_members == [], "phantom PART fanout to real members"
+ assert link_a in server._room_members.get("lobby", set())
+ assert link_b in server._room_members.get("lobby", set())
+ assert "lobby" in sess_a.rooms
+ assert "lobby" in sess_b.rooms
+
+
+def test_oracle_kick_fans_out_parted_and_blocks_with_no_outside():
+ """Kick must notify peers. With +n, kicked peers cannot keep messaging."""
+ server = make_server()
+ link_op, sess_op = add_session(server, b"\xaa" * 16, nick="op")
+ link_victim, sess_victim = add_session(server, b"\xbb" * 16, nick="victim")
+ join(server, link_op, sess_op, "lobby")
+ join(server, link_victim, sess_victim, "lobby")
+ assert server.rooms.is_room_op("lobby", sess_op.peer)
+ server.rooms.ensure_state("lobby")["no_outside_msgs"] = True
+
+ out = msg(server, link_op, sess_op, "lobby", "/kick lobby victim")
+ parted = envs_of_type(out, proto.T_PARTED, to_link=link_op)
+ assert parted, "remaining members must see PARTED after kick"
+ assert "lobby" not in sess_victim.rooms
+ assert link_victim not in server._room_members.get("lobby", set())
+
+ kicked_err = [
+ env
+ for lnk, env in out
+ if lnk is link_victim and env.get(proto.K_T) == proto.T_ERROR
+ ]
+ assert kicked_err
+ assert "kicked" in kicked_err[0][proto.K_BODY].lower()
+
+ after = msg(server, link_victim, sess_victim, "lobby", "still here")
+ assert envs_of_type(after, proto.T_ERROR, to_link=link_victim)
+ assert not any(
+ env.get(proto.K_T) == proto.T_MSG and env.get(proto.K_BODY) == "still here"
+ for lnk, env in after
+ if lnk is link_op
+ )
+
+
+def test_oracle_client_kick_error_leaves_joined_room(tmp_path):
+ """Client must drop a joined room when hub says kicked/banned."""
+ manager = RRCManager(
+ identity=FakeIdentity(b"\x11" * 16),
+ storage_dir=str(tmp_path),
+ )
+ hub = manager.add_hub(HUB_HASH, name="Client")
+ hub.rooms.add("lobby")
+ hub.members["lobby"] = {b"\x11" * 16, b"\xaa" * 16}
+ hub._handle_error(
+ proto.make_envelope(
+ proto.T_ERROR,
+ src=HUB_HASH,
+ room="lobby",
+ body="kicked from lobby",
+ ),
+ )
+ assert "lobby" not in hub.rooms
+ assert "lobby" not in hub.members
+
+
+def test_oracle_register_room_key_strips_like_join_paths():
+ """Hub key store paths must agree with JOIN after strip."""
+ server = make_server()
+ server.register_room("vault", key=" secret ")
+ stored = server.rooms.ensure_state("vault")["key"]
+ assert stored == "secret"
+
+ link, sess = add_session(server, b"\xdd" * 16, nick="dee")
+ good = join(server, link, sess, "vault", key="secret")
+ assert envs_of_type(good, proto.T_JOINED, to_link=link)
+
+ link2, sess2 = add_session(server, b"\xee" * 16, nick="ee")
+ wrong = join(server, link2, sess2, "vault", key="nope")
+ assert any(
+ e.get(proto.K_BODY) == "bad key (+k)"
+ for e in envs_of_type(wrong, proto.T_ERROR)
+ )
+
+
+def test_oracle_who_on_private_room_requires_membership():
+ server = make_server()
+ link_a, sess_a = add_session(server, b"\xaa" * 16, nick="alice")
+ link_spy, sess_spy = add_session(server, b"\xbb" * 16, nick="spy")
+ server.register_room("secret", private=True, founder=sess_a.peer)
+ join(server, link_a, sess_a, "secret")
+
+ out = msg(server, link_spy, sess_spy, "secret", "/who secret")
+ notices = envs_of_type(out, proto.T_NOTICE, to_link=link_spy)
+ assert notices
+ # Outsiders must not learn that a private room exists (same as empty room).
+ assert notices[0][proto.K_BODY] == "members in secret: (none)"
+ assert "alice" not in notices[0][proto.K_BODY].lower()
+
+ member_out = msg(server, link_a, sess_a, "secret", "/who secret")
+ member_notices = envs_of_type(member_out, proto.T_NOTICE, to_link=link_a)
+ assert member_notices
+ assert "alice" in member_notices[0][proto.K_BODY].lower()
+
+
+def test_oracle_who_private_existence_oracle_closed():
+ """Private registered vs nonexistent must look identical to outsiders."""
+ server = make_server()
+ link_a, sess_a = add_session(server, b"\xaa" * 16, nick="alice")
+ link_spy, sess_spy = add_session(server, b"\xbb" * 16, nick="spy")
+ server.register_room("secret", private=True, founder=sess_a.peer)
+ join(server, link_a, sess_a, "secret")
+
+ private_out = msg(server, link_spy, sess_spy, "lobby", "/who secret")
+ missing_out = msg(server, link_spy, sess_spy, "lobby", "/who nosuch")
+ private_body = envs_of_type(private_out, proto.T_NOTICE, to_link=link_spy)[0][
+ proto.K_BODY
+ ]
+ missing_body = envs_of_type(missing_out, proto.T_NOTICE, to_link=link_spy)[0][
+ proto.K_BODY
+ ]
+ assert private_body == "members in secret: (none)"
+ assert missing_body == "members in nosuch: (none)"
+ # Same response shape (only the room token differs).
+ assert private_body.replace("secret", "X") == missing_body.replace("nosuch", "X")
+
+
+def test_oracle_ban_list_requires_room_op():
+ server = make_server()
+ link_op, sess_op = add_session(server, b"\xaa" * 16, nick="op")
+ link_spy, sess_spy = add_session(server, b"\xbb" * 16, nick="spy")
+ join(server, link_op, sess_op, "lobby")
+ join(server, link_spy, sess_spy, "lobby")
+ banned = b"\xcc" * 16
+ server.rooms.ensure_state("lobby")["bans"].add(banned)
+
+ spy_out = msg(server, link_spy, sess_spy, "lobby", "/ban lobby list")
+ assert envs_of_type(spy_out, proto.T_ERROR, to_link=link_spy)
+ assert not any(
+ banned.hex() in (env.get(proto.K_BODY) or "")
+ for env in envs_of_type(spy_out, proto.T_NOTICE, to_link=link_spy)
+ )
+
+ op_out = msg(server, link_op, sess_op, "lobby", "/ban lobby list")
+ notices = envs_of_type(op_out, proto.T_NOTICE, to_link=link_op)
+ assert notices
+ assert banned.hex() in notices[0][proto.K_BODY]
+
+
+def test_oracle_unidentified_join_denied_for_keyed_room():
+ server = make_server()
+ server.register_room("vault", key="secret")
+ link = FakeLink(FakeIdentity(b"\xdd" * 16))
+ sess = _Session()
+ sess.peer = None
+ sess.nick = "ghost"
+ sess.welcomed = True
+ server._sessions[link] = sess
+ out = join(server, link, sess, "vault", key="secret")
+ assert any(
+ e.get(proto.K_BODY) == "not identified"
+ for e in envs_of_type(out, proto.T_ERROR)
+ )
+ assert "vault" not in sess.rooms
+
+
+def test_oracle_join_strips_key_whitespace():
+ server = make_server()
+ server.register_room("vault", key="secret")
+ link, sess = add_session(server, b"\xdd" * 16, nick="dee")
+ out = join(server, link, sess, "vault", key=" secret ")
+ assert envs_of_type(out, proto.T_JOINED, to_link=link)
+ assert "vault" in sess.rooms
+
+
+def test_oracle_invite_consumed_after_join():
+ server = make_server()
+ link_op, sess_op = add_session(server, b"\xaa" * 16, nick="op")
+ link_guest, sess_guest = add_session(server, b"\xbb" * 16, nick="guest")
+ server.register_room("club", invite_only=True, founder=sess_op.peer)
+ server.rooms.add_invite("club", sess_guest.peer, ttl_s=60)
+ first = join(server, link_guest, sess_guest, "club")
+ assert envs_of_type(first, proto.T_JOINED, to_link=link_guest)
+ assert not server.rooms.is_invited("club", sess_guest.peer)
+
+ part(server, link_guest, sess_guest, "club")
+ second = join(server, link_guest, sess_guest, "club")
+ assert any(
+ e.get(proto.K_BODY) == "invite-only (+i)"
+ for e in envs_of_type(second, proto.T_ERROR)
+ )
+
+
+def test_oracle_hub_rejects_oversized_room_key():
+ server = make_server()
+ with pytest.raises(ValueError):
+ server.set_room_key("vault", "x" * 300)
+
+
+def test_property_bad_key_detector_ignores_mode_hints():
+ assert RRCManager.is_bad_key_error("bad key (+k)") is True
+ assert RRCManager.is_bad_key_error("ERROR: Bad Key (+K)") is True
+ assert RRCManager.is_bad_key_error("failed: enable +k first") is False
+ assert RRCManager.is_bad_key_error("invite-only (+i)") is False
+ assert RRCManager.is_bad_key_error("+k") is False
+
+
+def test_oracle_rooms_toml_dotted_and_unicode_roundtrip(tmp_path):
+ path = str(tmp_path / "rooms.toml")
+ founder = b"\x11" * 16
+ registry = {
+ "a.b": {
+ "founder": founder,
+ "registered": True,
+ "topic": "dotted",
+ "moderated": False,
+ "invite_only": True,
+ "topic_ops_only": False,
+ "no_outside_msgs": True,
+ "private": False,
+ "key": "s3cret",
+ "ops": {founder},
+ "voiced": set(),
+ "bans": set(),
+ "invited": {},
+ "last_used_ts": 123.0,
+ },
+ "café": {
+ "founder": founder,
+ "registered": True,
+ "topic": "unicode",
+ "moderated": True,
+ "invite_only": False,
+ "topic_ops_only": True,
+ "no_outside_msgs": False,
+ "private": True,
+ "key": None,
+ "ops": {founder},
+ "voiced": set(),
+ "bans": set(),
+ "invited": {},
+ "last_used_ts": 456.0,
+ },
+ }
+ dump_rooms_registry(path, registry)
+ loaded = load_rooms_registry(path)
+ assert "a.b" in loaded
+ assert loaded["a.b"]["key"] == "s3cret"
+ assert loaded["a.b"]["invite_only"] is True
+ assert loaded["a.b"]["no_outside_msgs"] is True
+ assert "café" in loaded
+ assert loaded["café"]["moderated"] is True
+ assert loaded["café"]["private"] is True
+ assert loaded["café"]["topic"] == "unicode"
+
+
+def test_oracle_invite_ttl_boundary_denies_join():
+ server = make_server()
+ link_op, sess_op = add_session(server, b"\xaa" * 16, nick="op")
+ link_guest, sess_guest = add_session(server, b"\xbb" * 16, nick="guest")
+ server.register_room("club", invite_only=True, founder=sess_op.peer)
+ server.rooms.add_invite("club", sess_guest.peer, ttl_s=0.05)
+ time.sleep(0.08)
+ out = join(server, link_guest, sess_guest, "club")
+ assert any(
+ e.get(proto.K_BODY) == "invite-only (+i)"
+ for e in envs_of_type(out, proto.T_ERROR)
+ )
+ assert "club" not in sess_guest.rooms
+
+
+def test_oracle_double_join_is_idempotent_for_peers():
+ server = make_server()
+ link_a, sess_a = add_session(server, b"\xaa" * 16, nick="alice")
+ link_b, sess_b = add_session(server, b"\xbb" * 16, nick="bob")
+ join(server, link_a, sess_a, "lobby")
+ join(server, link_b, sess_b, "lobby")
+ out = join(server, link_b, sess_b, "lobby")
+ fanouts = [
+ env
+ for lnk, env in out
+ if lnk is link_a and env.get(proto.K_T) == proto.T_JOINED
+ ]
+ assert fanouts == [], "re-JOIN must not re-announce JOINED to peers"
+
+
+@given(
+ room=st.from_regex(r"[a-z0-9_-]{1,16}", fullmatch=True),
+ key=st.one_of(st.none(), st.from_regex(r"[a-zA-Z0-9_-]{1,24}", fullmatch=True)),
+)
+@settings(max_examples=40, deadline=None)
+def test_property_rooms_toml_simple_names_roundtrip(tmp_path_factory, room, key):
+ path = str(tmp_path_factory.mktemp("rrc") / "rooms.toml")
+ name = room.strip().lower()
+ founder = b"\x33" * 16
+ key_n = key if isinstance(key, str) and key else None
+ registry = {
+ name: {
+ "founder": founder,
+ "registered": True,
+ "topic": None,
+ "moderated": False,
+ "invite_only": False,
+ "topic_ops_only": False,
+ "no_outside_msgs": False,
+ "private": False,
+ "key": key_n,
+ "ops": {founder},
+ "voiced": set(),
+ "bans": set(),
+ "invited": {},
+ "last_used_ts": 1.0,
+ },
+ }
+ dump_rooms_registry(path, registry)
+ loaded = load_rooms_registry(path)
+ assert name in loaded
+ assert loaded[name]["key"] == registry[name]["key"]
+ assert loaded[name]["registered"] is True
+
+
+@given(
+ wrong=st.text(min_size=1, max_size=24).filter(
+ lambda s: s.strip() not in ("", "ok"),
+ ),
+)
+@settings(max_examples=30, deadline=None)
+def test_property_keyed_join_rejects_non_matching_keys(wrong):
+ server = make_server()
+ server.register_room("vault", key="ok")
+ link, sess = add_session(server, os.urandom(16), nick="x")
+ provided = wrong.strip()
+ if provided == "ok":
+ return
+ out = join(server, link, sess, "vault", key=provided)
+ assert any(
+ e.get(proto.K_BODY) == "bad key (+k)" for e in envs_of_type(out, proto.T_ERROR)
+ )
+ assert "vault" not in sess.rooms
+ assert server.rooms.ensure_state("vault").get("founder") is None

diff --git a/tests/backend/test_rrc_room_keys.py b/tests/backend/test_rrc_room_keys.py
new file mode 100644
index 00000000..83578ea4
--- /dev/null
+++ b/tests/backend/test_rrc_room_keys.py
@@ -0,0 +1,461 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Tests for RRC +k room key crypto, storage, hub checks, and API."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import MagicMock
+
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from meshchatx.src.backend.rrc import protocol as proto
+from meshchatx.src.backend.rrc.manager import RRCHub, RRCManager
+from meshchatx.src.backend.rrc.room_key_crypto import (
+ decrypt_room_key,
+ encrypt_room_key,
+ room_keys_equal,
+)
+from meshchatx.src.backend.rrc.server import RRCHubServer, _Session
+
+HUB_HASH_HEX = "00112233445566778899aabbccddeeff"
+HUB_HASH = bytes.fromhex(HUB_HASH_HEX)
+PRIVATE_A = b"identity-private-key-aaaa"
+PRIVATE_B = b"identity-private-key-bbbb"
+
+
+class _Identity:
+ def __init__(self, hash_bytes, private_key):
+ self.hash = hash_bytes
+ self._private_key = private_key
+
+ def get_private_key(self):
+ return self._private_key
+
+
+class _FakeLink:
+ def __init__(self, identity):
+ self._identity = identity
+
+ def get_remote_identity(self):
+ return self._identity
+
+
+def _find_handler(app, path, method):
+ for route in app.get_routes():
+ if route.path == path and route.method == method:
+ return route.handler
+ return None
+
+
+def _make_request(json_body=None, match_info=None):
+ request = MagicMock()
+
+ async def _json():
+ return json_body if json_body is not None else {}
+
+ request.json = _json
+ request.match_info = match_info or {}
+ request.query = {}
+ return request
+
+
+def test_encrypt_decrypt_roundtrip():
+ nonce, ciphertext = encrypt_room_key(PRIVATE_A, " secret-key ")
+ assert decrypt_room_key(PRIVATE_A, nonce, ciphertext) == "secret-key"
+ assert b"secret-key" not in ciphertext
+ assert b"secret" not in ciphertext
+
+
+def test_decrypt_fails_for_other_identity():
+ nonce, ciphertext = encrypt_room_key(PRIVATE_A, "secret-key")
+ with pytest.raises(Exception):
+ decrypt_room_key(PRIVATE_B, nonce, ciphertext)
+
+
+@pytest.mark.parametrize(
+ "bad",
+ [
+ "",
+ " ",
+ None,
+ 123,
+ "x" * 300,
+ ],
+)
+def test_encrypt_rejects_invalid_keys(bad):
+ with pytest.raises((TypeError, ValueError)):
+ encrypt_room_key(PRIVATE_A, bad)
+
+
+@given(
+ left=st.text(min_size=1, max_size=32).filter(lambda s: bool(s.strip())),
+ pad_l=st.integers(min_value=0, max_value=3),
+ pad_r=st.integers(min_value=0, max_value=3),
+)
+@settings(max_examples=40, deadline=None)
+def test_property_room_keys_equal_strips(left, pad_l, pad_r):
+ expected = left.strip()
+ provided = (" " * pad_l) + expected + (" " * pad_r)
+ assert room_keys_equal(provided, expected) is True
+ assert room_keys_equal(expected + "x", expected) is False
+ assert room_keys_equal(None, expected) is False
+
+
+def test_dao_upsert_get_delete(db):
+ nonce, ciphertext = encrypt_room_key(PRIVATE_A, "alpha")
+ db.rrc_room_keys.upsert(HUB_HASH_HEX, "rrc.hub", "lobby", nonce, ciphertext)
+ row = db.rrc_room_keys.get(HUB_HASH_HEX, "rrc.hub", "lobby")
+ assert row is not None
+ assert decrypt_room_key(PRIVATE_A, row["nonce"], row["ciphertext"]) == "alpha"
+
+ nonce2, ciphertext2 = encrypt_room_key(PRIVATE_A, "beta")
+ db.rrc_room_keys.upsert(HUB_HASH_HEX, "rrc.hub", "lobby", nonce2, ciphertext2)
+ row2 = db.rrc_room_keys.get(HUB_HASH_HEX, "rrc.hub", "lobby")
+ assert decrypt_room_key(PRIVATE_A, row2["nonce"], row2["ciphertext"]) == "beta"
+
+ listed = db.rrc_room_keys.list_for_hub(HUB_HASH_HEX, "rrc.hub")
+ assert len(listed) == 1
+ assert "ciphertext" not in listed[0].keys() or listed[0].get("ciphertext") is None
+
+ assert db.rrc_room_keys.delete(HUB_HASH_HEX, "rrc.hub", "lobby") == 1
+ assert db.rrc_room_keys.get(HUB_HASH_HEX, "rrc.hub", "lobby") is None
+
+
+def test_manager_remember_get_forget(db, tmp_path):
+ identity = _Identity(b"\x11" * 16, PRIVATE_A)
+ manager = RRCManager(identity, str(tmp_path), database=db)
+ hub = manager.add_hub(HUB_HASH, name="Hub")
+ manager.remember_room_key(hub, "Lobby", "correct-horse")
+ assert manager.get_room_key(hub, "lobby") == "correct-horse"
+ assert manager.has_stored_room_key(hub, "lobby") is True
+ keys = manager.list_stored_room_keys(hub)
+ assert keys == [
+ {
+ "hub_hash": HUB_HASH_HEX,
+ "dest_name": "rrc.hub",
+ "room": "lobby",
+ "updated_at": keys[0]["updated_at"],
+ },
+ ]
+ assert "key" not in keys[0]
+ assert manager.forget_room_key(hub, "lobby") == 1
+ assert manager.get_room_key(hub, "lobby") is None
+
+
+def test_manager_wrong_identity_returns_none(db, tmp_path):
+ manager_a = RRCManager(
+ _Identity(b"\x11" * 16, PRIVATE_A), str(tmp_path), database=db
+ )
+ hub = manager_a.add_hub(HUB_HASH)
+ manager_a.remember_room_key(hub, "lobby", "secret")
+
+ manager_b = RRCManager(
+ _Identity(b"\x22" * 16, PRIVATE_B), str(tmp_path), database=db
+ )
+ hub_b = manager_b.add_hub(HUB_HASH)
+ assert manager_b.get_room_key(hub_b, "lobby") is None
+ assert manager_b.has_stored_room_key(hub_b, "lobby") is True
+
+
+def test_bad_key_error_detector():
+ assert RRCManager.is_bad_key_error("bad key (+k)") is True
+ assert RRCManager.is_bad_key_error("ERROR: Bad Key (+K)") is True
+ assert RRCManager.is_bad_key_error("invite-only (+i)") is False
+ assert RRCManager.is_bad_key_error("failed: enable +k first") is False
+ assert RRCManager.is_bad_key_error("+k") is False
+ assert RRCManager.is_bad_key_error(None) is False
+
+
+def test_handle_error_forgets_bad_key(db, tmp_path):
+ identity = _Identity(b"\x11" * 16, PRIVATE_A)
+ manager = RRCManager(identity, str(tmp_path), database=db)
+ hub = manager.add_hub(HUB_HASH)
+ manager.remember_room_key(hub, "lobby", "wrong")
+ with hub._lock:
+ hub._pending_joins.add("lobby")
+ hub.rooms.add("lobby")
+
+ hub._handle_error(
+ proto.make_envelope(
+ proto.T_ERROR,
+ src=HUB_HASH,
+ room="lobby",
+ body="bad key (+k)",
+ ),
+ )
+ assert manager.get_room_key(hub, "lobby") is None
+ assert "lobby" not in hub.rooms
+
+
+def test_welcome_rejoin_uses_stored_key(db, tmp_path, monkeypatch):
+ identity = _Identity(b"\x11" * 16, PRIVATE_A)
+ manager = RRCManager(identity, str(tmp_path), database=db)
+ hub = manager.add_hub(HUB_HASH)
+ hub.add_room("secret")
+ manager.remember_room_key(hub, "secret", "hunter2")
+
+ seen = {}
+
+ def _fake_join(room, key=None, silent=False):
+ seen["room"] = room
+ seen["key"] = key
+ seen["silent"] = silent
+
+ monkeypatch.setattr(hub, "join_room", _fake_join)
+ manager._on_welcome(hub)
+ assert seen == {"room": "secret", "key": "hunter2", "silent": True}
+
+
+def test_hub_rejects_wrong_key_and_accepts_correct():
+ owner = _Identity(HUB_HASH, PRIVATE_A)
+ server = RRCHubServer(MagicMock(), owner, name="Keyed Hub")
+ server.register_room("vault", key="correct")
+ cfg = server.rooms.room_config_for_api("vault")
+ assert cfg["has_key"] is True
+ assert "key" not in cfg
+
+ peer = _Identity(b"\xaa" * 16, b"peer-key")
+ link = _FakeLink(peer)
+ sess = _Session()
+ sess.peer = peer.hash
+ sess.nick = "alice"
+ sess.welcomed = True
+ server._sessions[link] = sess
+
+ bad_out = []
+ server._route(
+ link,
+ sess,
+ proto.make_envelope(proto.T_JOIN, src=peer.hash, room="vault", body="wrong"),
+ bad_out,
+ )
+ bad_envs = [proto.decode(payload) for _, payload in bad_out]
+ assert any(
+ e[proto.K_T] == proto.T_ERROR and e[proto.K_BODY] == "bad key (+k)"
+ for e in bad_envs
+ )
+ assert "vault" not in sess.rooms
+ assert server.rooms.ensure_state("vault").get("founder") is None
+
+ good_out = []
+ server._route(
+ link,
+ sess,
+ proto.make_envelope(proto.T_JOIN, src=peer.hash, room="vault", body="correct"),
+ good_out,
+ )
+ good_envs = [proto.decode(payload) for _, payload in good_out]
+ assert any(e[proto.K_T] == proto.T_JOINED for e in good_envs)
+ assert server.rooms.is_room_op("vault", peer.hash) is True
+
+
+def test_hub_invite_only_checked_before_founder_promotion():
+ owner = _Identity(HUB_HASH, PRIVATE_A)
+ server = RRCHubServer(MagicMock(), owner, name="Invite Hub")
+ server.register_room("private", invite_only=True)
+ peer = _Identity(b"\xbb" * 16, b"peer-key")
+ link = _FakeLink(peer)
+ sess = _Session()
+ sess.peer = peer.hash
+ sess.nick = "bob"
+ sess.welcomed = True
+ server._sessions[link] = sess
+
+ out = []
+ server._route(
+ link,
+ sess,
+ proto.make_envelope(proto.T_JOIN, src=peer.hash, room="private"),
+ out,
+ )
+ envs = [proto.decode(payload) for _, payload in out]
+ assert any(
+ e[proto.K_T] == proto.T_ERROR and e[proto.K_BODY] == "invite-only (+i)"
+ for e in envs
+ )
+ assert server.rooms.ensure_state("private").get("founder") is None
+
+
+def test_set_room_key_clear_and_persist(tmp_path):
+ owner = _Identity(HUB_HASH, PRIVATE_A)
+ server = RRCHubServer(MagicMock(), owner, name="Keyed Hub")
+ server.register_room("vault", key="one")
+ assert server.rooms.ensure_state("vault")["key"] == "one"
+ server.set_room_key("vault", "two")
+ assert server.rooms.ensure_state("vault")["key"] == "two"
+ server.set_room_key("vault", None)
+ assert not server.rooms.ensure_state("vault")["key"]
+
+
+@pytest.mark.asyncio
+async def test_api_store_list_delete_room_key(mock_app):
+ post_hub = _find_handler(mock_app, "/api/v1/rrc/hubs", "POST")
+ await post_hub(_make_request(json_body={"hub_hash": HUB_HASH_HEX}))
+
+ put_key = _find_handler(
+ mock_app,
+ "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/key",
+ "PUT",
+ )
+ response = await put_key(
+ _make_request(
+ json_body={"key": "secret"},
+ match_info={"hub_hash": HUB_HASH_HEX, "room": "lobby"},
+ ),
+ )
+ assert response.status == 200
+ body = json.loads(response.body)
+ assert body["has_stored_key"] is True
+ assert "secret" not in response.body.decode()
+
+ list_keys = _find_handler(mock_app, "/api/v1/rrc/hubs/{hub_hash}/room-keys", "GET")
+ listing = json.loads(
+ (
+ await list_keys(
+ _make_request(match_info={"hub_hash": HUB_HASH_HEX}),
+ )
+ ).body,
+ )
+ assert listing["keys"][0]["room"] == "lobby"
+ assert "key" not in listing["keys"][0]
+ assert "ciphertext" not in listing["keys"][0]
+
+ delete_key = _find_handler(
+ mock_app,
+ "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/key",
+ "DELETE",
+ )
+ deleted = await delete_key(
+ _make_request(match_info={"hub_hash": HUB_HASH_HEX, "room": "lobby"}),
+ )
+ assert deleted.status == 200
+ assert json.loads(deleted.body)["deleted"] == 1
+
+
+@pytest.mark.asyncio
+async def test_api_join_offline_remembers_key(mock_app):
+ post_hub = _find_handler(mock_app, "/api/v1/rrc/hubs", "POST")
+ await post_hub(_make_request(json_body={"hub_hash": HUB_HASH_HEX}))
+
+ join = _find_handler(mock_app, "/api/v1/rrc/hubs/{hub_hash}/rooms", "POST")
+ response = await join(
+ _make_request(
+ json_body={"room": "vault", "key": "offline-secret", "remember": True},
+ match_info={"hub_hash": HUB_HASH_HEX},
+ ),
+ )
+ assert response.status == 200
+ data = json.loads(response.body)
+ assert data["has_stored_key"] is True
+ assert "offline-secret" not in response.body.decode()
+
+ hub = mock_app.rrc_manager.find_hub(HUB_HASH)
+ assert mock_app.rrc_manager.get_room_key(hub, "vault") == "offline-secret"
+
+
+@pytest.mark.asyncio
+async def test_api_join_uses_stored_key_when_connected(mock_app, monkeypatch):
+ post_hub = _find_handler(mock_app, "/api/v1/rrc/hubs", "POST")
+ await post_hub(_make_request(json_body={"hub_hash": HUB_HASH_HEX}))
+ hub = mock_app.rrc_manager.find_hub(HUB_HASH)
+ mock_app.rrc_manager.remember_room_key(hub, "vault", "stored-secret")
+
+ with hub._lock:
+ hub.status = RRCHub.STATUS_CONNECTED
+
+ seen = {}
+
+ def _fake_join(room, key=None, silent=False):
+ seen["room"] = room
+ seen["key"] = key
+
+ monkeypatch.setattr(hub, "join_room", _fake_join)
+
+ join = _find_handler(mock_app, "/api/v1/rrc/hubs/{hub_hash}/rooms", "POST")
+ response = await join(
+ _make_request(
+ json_body={"room": "vault"},
+ match_info={"hub_hash": HUB_HASH_HEX},
+ ),
+ )
+ assert response.status == 200
+ assert seen == {"room": "vault", "key": "stored-secret"}
+
+
+@pytest.mark.asyncio
+async def test_api_put_key_rejects_empty(mock_app):
+ post_hub = _find_handler(mock_app, "/api/v1/rrc/hubs", "POST")
+ await post_hub(_make_request(json_body={"hub_hash": HUB_HASH_HEX}))
+ put_key = _find_handler(
+ mock_app,
+ "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/key",
+ "PUT",
+ )
+ response = await put_key(
+ _make_request(
+ json_body={"key": " "},
+ match_info={"hub_hash": HUB_HASH_HEX, "room": "lobby"},
+ ),
+ )
+ assert response.status == 400
+
+
+@pytest.mark.asyncio
+async def test_api_join_remember_false_skips_storage(mock_app):
+ post_hub = _find_handler(mock_app, "/api/v1/rrc/hubs", "POST")
+ await post_hub(_make_request(json_body={"hub_hash": HUB_HASH_HEX}))
+ join = _find_handler(mock_app, "/api/v1/rrc/hubs/{hub_hash}/rooms", "POST")
+ response = await join(
+ _make_request(
+ json_body={"room": "vault", "key": "ephemeral", "remember": False},
+ match_info={"hub_hash": HUB_HASH_HEX},
+ ),
+ )
+ assert response.status == 200
+ data = json.loads(response.body)
+ assert data["has_stored_key"] is False
+ hub = mock_app.rrc_manager.find_hub(HUB_HASH)
+ assert mock_app.rrc_manager.get_room_key(hub, "vault") is None
+
+
+@pytest.mark.asyncio
+async def test_host_api_set_and_clear_room_key(mock_app):
+ create = _find_handler(mock_app, "/api/v1/rrc/servers", "POST")
+ created = await create(
+ _make_request(json_body={"name": "Host Hub", "enabled": False}),
+ )
+ assert created.status == 200
+ hub_id = json.loads(created.body)["hub"]["id"]
+
+ add_room = _find_handler(mock_app, "/api/v1/rrc/servers/{hub_id}/rooms", "POST")
+ room_resp = await add_room(
+ _make_request(
+ json_body={"name": "vault", "key": "initial"},
+ match_info={"hub_id": hub_id},
+ ),
+ )
+ assert room_resp.status == 200
+ rooms = json.loads(room_resp.body)["hub"]["rooms"]
+ vault = next(r for r in rooms if r["name"] == "vault")
+ assert vault["has_key"] is True
+ assert "key" not in vault
+ assert b"initial" not in room_resp.body
+
+ set_key = _find_handler(
+ mock_app,
+ "/api/v1/rrc/servers/{hub_id}/rooms/{room}/key",
+ "PUT",
+ )
+ cleared = await set_key(
+ _make_request(
+ json_body={"key": None},
+ match_info={"hub_id": hub_id, "room": "vault"},
+ ),
+ )
+ assert cleared.status == 200
+ rooms2 = json.loads(cleared.body)["hub"]["rooms"]
+ vault2 = next(r for r in rooms2 if r["name"] == "vault")
+ assert vault2["has_key"] is False

diff --git a/tests/backend/test_smoke_extended.py b/tests/backend/test_smoke_extended.py
index fc8fead7..d4768c6c 100644
--- a/tests/backend/test_smoke_extended.py
+++ b/tests/backend/test_smoke_extended.py
@@ -254,4 +254,4 @@ def test_lxmf_router_creation_accepts_new_defaults():
assert "sequential_validation" in params
assert "static_sequential" in params
assert hasattr(LXMF.LXMRouter, "inbound_count")
- assert hasattr(LXMF.LXMRouter, "cancel_all_inbound")
\ No newline at end of file
+ assert hasattr(LXMF.LXMRouter, "cancel_all_inbound")

diff --git a/tests/frontend/DialogUtils.prompt.test.js b/tests/frontend/DialogUtils.prompt.test.js
index 851e933e..b8d648b0 100644
--- a/tests/frontend/DialogUtils.prompt.test.js
+++ b/tests/frontend/DialogUtils.prompt.test.js
@@ -46,4 +46,24 @@ describe("DialogUtils.prompt", () => {
payload.resolve(null);
await expect(pending).resolves.toBeNull();
});
+
+ it("uses in-app password prompt and skips electron.prompt for password type", async () => {
+ window.electron = {
+ prompt: vi.fn().mockResolvedValue("should-not-use"),
+ };
+ const pending = DialogUtils.prompt("Room key", "", { inputType: "password" });
+ expect(window.electron.prompt).not.toHaveBeenCalled();
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith(
+ "prompt",
+ expect.objectContaining({
+ message: "Room key",
+ defaultValue: "",
+ inputType: "password",
+ resolve: expect.any(Function),
+ })
+ );
+ const payload = GlobalEmitter.emit.mock.calls.find((c) => c[0] === "prompt")[1];
+ payload.resolve("secret");
+ await expect(pending).resolves.toBe("secret");
+ });
});

diff --git a/tests/frontend/MicronParser.test.js b/tests/frontend/MicronParser.test.js
index 73734c64..d20bf98b 100644
--- a/tests/frontend/MicronParser.test.js
+++ b/tests/frontend/MicronParser.test.js
@@ -604,6 +604,23 @@ Content at depth 1`;
expect(html).not.toMatch(/<style[\s>]/i);
});
+ it("sanitizeRenderedMicronHtml strips @import and https url from style blocks", () => {
+ const html = MicronParser.sanitizeRenderedMicronHtml(
+ '<style>@import url("https://evil.com/a.css"); p{background:url(https://evil.com/x.png)}</style><p>ok</p>'
+ );
+ expect(html.toLowerCase()).not.toContain("@import");
+ expect(html).not.toMatch(/url\s*\(\s*["']?https?:\/\//i);
+ expect(html).toContain("ok");
+ });
+
+ it("stripOverlayStyles drops inline network url()", () => {
+ const html = MicronParser.stripOverlayStyles(
+ `<div style="color:red; background:url(https://evil.com/x)">x</div>`
+ );
+ expect(html).not.toMatch(/url\s*\(\s*["']?https?:\/\//i);
+ expect(html).toContain("color:red");
+ });
+
it("blocks javascript: in link with encoding", () => {
const markup = "`[click`java\tscript:alert(1)]";
const html = parser.convertMicronToHtml(markup);

diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js
index f4c7c738..5258ab66 100644
--- a/tests/frontend/NomadNetworkPage.test.js
+++ b/tests/frontend/NomadNetworkPage.test.js
@@ -346,6 +346,41 @@ describe("NomadNetworkPage.vue", () => {
downloadSpy.mockRestore();
});
+ it("processPartials floors hostile 1s refresh to at least 5s", async () => {
+ vi.useFakeTimers();
+ const dest = "c".repeat(32);
+ const wrapper = mountNomadNetworkPage();
+ wrapper.vm.selectedNode = { destination_hash: dest, display_name: "Test" };
+ wrapper.vm.nodePagePath = `${dest}:/page/index.mu`;
+ wrapper.vm.nodePageContent = "`{" + dest + ":/page/nested.mu`1}";
+ wrapper.vm.isShowingNodePageSource = false;
+
+ const downloadSpy = vi
+ .spyOn(wrapper.vm, "downloadNomadNetPage")
+ .mockImplementation((_d, _p, _f, onSuccess) => {
+ onSuccess("# ok");
+ });
+
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.processPartials();
+ await wrapper.vm.$nextTick();
+
+ const afterFirst = downloadSpy.mock.calls.length;
+ expect(afterFirst).toBeGreaterThanOrEqual(1);
+ expect(wrapper.vm.partialRefreshByKey[`${dest}:/page/nested.mu`]).toBe(5);
+
+ vi.advanceTimersByTime(1000);
+ expect(downloadSpy.mock.calls.length).toBe(afterFirst);
+
+ vi.advanceTimersByTime(4000);
+ await wrapper.vm.$nextTick();
+ expect(downloadSpy.mock.calls.length).toBeGreaterThan(afterFirst);
+
+ downloadSpy.mockRestore();
+ vi.useRealTimers();
+ });
+
it("does not re-run Micron conversion when only favourites list updates", async () => {
const dest = "b".repeat(32);
const wrapper = mountNomadNetworkPage();

diff --git a/tests/frontend/RelayChatPage.test.js b/tests/frontend/RelayChatPage.test.js
index a4df5779..40297b98 100644
--- a/tests/frontend/RelayChatPage.test.js
+++ b/tests/frontend/RelayChatPage.test.js
@@ -175,7 +175,65 @@ describe("RelayChatPage.vue", () => {
await wrapper.vm.joinAvailableRoom(wrapper.vm.hubs[0], "random");
- expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB_HASH}/rooms`, { room: "random" });
+ expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB_HASH}/rooms`, {
+ room: "random",
+ remember: true,
+ });
+ });
+
+ it("includes an optional room key when joining a room", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
+ wrapper.vm.joinRoomName = "vault";
+ wrapper.vm.joinRoomKey = "hunter2";
+ await wrapper.vm.joinRoom(wrapper.vm.hubs[0]);
+ expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB_HASH}/rooms`, {
+ room: "vault",
+ remember: true,
+ key: "hunter2",
+ });
+ expect(wrapper.vm.joinRoomName).toBe("");
+ expect(wrapper.vm.joinRoomKey).toBe("");
+ });
+
+ it("prompts for a password after a bad key websocket error", async () => {
+ const DialogUtils = (await import("@/js/DialogUtils")).default;
+ const ToastUtils = (await import("@/js/ToastUtils")).default;
+ vi.spyOn(DialogUtils, "prompt").mockResolvedValue("correct-key");
+ vi.spyOn(ToastUtils, "warning").mockImplementation(() => {});
+ vi.spyOn(ToastUtils, "info").mockImplementation(() => {});
+ vi.spyOn(ToastUtils, "error").mockImplementation(() => {});
+ vi.spyOn(ToastUtils, "success").mockImplementation(() => {});
+
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
+
+ await wrapper.vm.onWebsocketMessage({
+ data: JSON.stringify({
+ type: "rrc.message",
+ hub_hash: HUB_HASH,
+ room: "vault",
+ message: {
+ kind: "error",
+ room: "vault",
+ text: "bad key (+k)",
+ },
+ }),
+ });
+
+ await vi.waitFor(() =>
+ expect(DialogUtils.prompt).toHaveBeenCalledWith(expect.stringContaining("vault"), "", {
+ inputType: "password",
+ })
+ );
+ await vi.waitFor(() =>
+ expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB_HASH}/rooms`, {
+ room: "vault",
+ remember: true,
+ key: "correct-key",
+ })
+ );
+ expect(axiosMock.delete).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB_HASH}/rooms/vault/key`);
});
it("loads messages and members when selecting a room", async () => {


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────